From 5bb5a05a25bb40172f7bbd52ab4bd3c0d8a25f17 Mon Sep 17 00:00:00 2001 From: Hafid Nur Date: Sat, 14 Feb 2026 16:38:15 +0700 Subject: [PATCH] Develop QR code generator app for campaign --- apps/qrcampaign/index.html | 8 +- apps/qrcampaign/postcss.config.mjs | 9 + apps/qrcampaign/public/favicon.ico | Bin 15086 -> 0 bytes .../qrcampaign/public/images/imphnen-logo.svg | 9 + apps/qrcampaign/src/app/404.tsx | 23 + .../src/app/admin/campaigns/page.tsx | 262 ++++++ apps/qrcampaign/src/app/admin/layout.tsx | 10 + apps/qrcampaign/src/app/admin/page.tsx | 3 + apps/qrcampaign/src/app/admin/users/page.tsx | 199 ++++ apps/qrcampaign/src/app/app.module.css | 1 - apps/qrcampaign/src/app/app.spec.tsx | 26 - .../qrcampaign/src/app/auth/callback/page.tsx | 183 ++++ .../src/app/auth/forgot-password/page.tsx | 125 +++ apps/qrcampaign/src/app/auth/login/page.tsx | 234 +++++ .../src/app/auth/reset-password/page.tsx | 171 ++++ apps/qrcampaign/src/app/auth/signup/page.tsx | 305 +++++++ apps/qrcampaign/src/app/error.tsx | 29 + .../features/admin/api/campaign.service.ts | 56 ++ .../app/features/admin/api/user.service.ts | 53 ++ .../admin/components/RequireAdmin.tsx | 27 + .../features/admin/pages/AdminDashboard.tsx | 64 ++ .../admin/pages/CampaignManagement.tsx | 237 +++++ .../features/admin/pages/UserManagement.tsx | 121 +++ .../src/app/features/auth/api/auth.service.ts | 129 +++ .../features/auth/components/RequireAuth.tsx | 21 + .../src/app/features/auth/pages/LoginPage.tsx | 102 +++ .../src/app/features/auth/store/auth.store.ts | 84 ++ .../campaign/api/useActiveCampaignQR.ts | 17 + .../watermark/components/Dropzone.tsx | 99 ++ .../watermark/components/WatermarkEditor.tsx | 188 ++++ apps/qrcampaign/src/app/layout.tsx | 83 ++ apps/qrcampaign/src/app/nx-welcome.tsx | 856 ------------------ apps/qrcampaign/src/app/page.tsx | 180 +++- apps/qrcampaign/src/components/Sidebar.tsx | 229 +++++ apps/qrcampaign/src/index.css | 136 +++ apps/qrcampaign/src/main.tsx | 46 +- 36 files changed, 3384 insertions(+), 941 deletions(-) create mode 100644 apps/qrcampaign/postcss.config.mjs delete mode 100644 apps/qrcampaign/public/favicon.ico create mode 100644 apps/qrcampaign/public/images/imphnen-logo.svg create mode 100644 apps/qrcampaign/src/app/404.tsx create mode 100644 apps/qrcampaign/src/app/admin/campaigns/page.tsx create mode 100644 apps/qrcampaign/src/app/admin/layout.tsx create mode 100644 apps/qrcampaign/src/app/admin/page.tsx create mode 100644 apps/qrcampaign/src/app/admin/users/page.tsx delete mode 100644 apps/qrcampaign/src/app/app.module.css delete mode 100644 apps/qrcampaign/src/app/app.spec.tsx create mode 100644 apps/qrcampaign/src/app/auth/callback/page.tsx create mode 100644 apps/qrcampaign/src/app/auth/forgot-password/page.tsx create mode 100644 apps/qrcampaign/src/app/auth/login/page.tsx create mode 100644 apps/qrcampaign/src/app/auth/reset-password/page.tsx create mode 100644 apps/qrcampaign/src/app/auth/signup/page.tsx create mode 100644 apps/qrcampaign/src/app/error.tsx create mode 100644 apps/qrcampaign/src/app/features/admin/api/campaign.service.ts create mode 100644 apps/qrcampaign/src/app/features/admin/api/user.service.ts create mode 100644 apps/qrcampaign/src/app/features/admin/components/RequireAdmin.tsx create mode 100644 apps/qrcampaign/src/app/features/admin/pages/AdminDashboard.tsx create mode 100644 apps/qrcampaign/src/app/features/admin/pages/CampaignManagement.tsx create mode 100644 apps/qrcampaign/src/app/features/admin/pages/UserManagement.tsx create mode 100644 apps/qrcampaign/src/app/features/auth/api/auth.service.ts create mode 100644 apps/qrcampaign/src/app/features/auth/components/RequireAuth.tsx create mode 100644 apps/qrcampaign/src/app/features/auth/pages/LoginPage.tsx create mode 100644 apps/qrcampaign/src/app/features/auth/store/auth.store.ts create mode 100644 apps/qrcampaign/src/app/features/campaign/api/useActiveCampaignQR.ts create mode 100644 apps/qrcampaign/src/app/features/watermark/components/Dropzone.tsx create mode 100644 apps/qrcampaign/src/app/features/watermark/components/WatermarkEditor.tsx create mode 100644 apps/qrcampaign/src/app/layout.tsx delete mode 100644 apps/qrcampaign/src/app/nx-welcome.tsx create mode 100644 apps/qrcampaign/src/components/Sidebar.tsx create mode 100644 apps/qrcampaign/src/index.css diff --git a/apps/qrcampaign/index.html b/apps/qrcampaign/index.html index bba4138..78379f6 100644 --- a/apps/qrcampaign/index.html +++ b/apps/qrcampaign/index.html @@ -2,12 +2,12 @@ - Qrcampaign + QR Code Generator - IMPHNEN - - - + + +
diff --git a/apps/qrcampaign/postcss.config.mjs b/apps/qrcampaign/postcss.config.mjs new file mode 100644 index 0000000..63252e5 --- /dev/null +++ b/apps/qrcampaign/postcss.config.mjs @@ -0,0 +1,9 @@ +import { join } from 'path'; + +export default { + plugins: { + '@tailwindcss/postcss': { + base: join(import.meta.dirname, '../../'), + }, + }, +}; diff --git a/apps/qrcampaign/public/favicon.ico b/apps/qrcampaign/public/favicon.ico deleted file mode 100644 index 317ebcb2336e0833a22dddf0ab287849f26fda57..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15086 zcmeI332;U^%p|z7g|#(P)qFEA@4f!_@qOK2 z_lJl}!lhL!VT_U|uN7%8B2iKH??xhDa;*`g{yjTFWHvXn;2s{4R7kH|pKGdy(7z!K zgftM+Ku7~24TLlh(!g)gz|foI94G^t2^IO$uvX$3(OR0<_5L2sB)lMAMy|+`xodJ{ z_Uh_1m)~h?a;2W{dmhM;u!YGo=)OdmId_B<%^V^{ovI@y`7^g1_V9G}*f# zNzAtvou}I!W1#{M^@ROc(BZ! z+F!!_aR&Px3_reO(EW+TwlW~tv*2zr?iP7(d~a~yA|@*a89IUke+c472NXM0wiX{- zl`UrZC^1XYyf%1u)-Y)jj9;MZ!SLfd2Hl?o|80Su%Z?To_=^g_Jt0oa#CT*tjx>BI z16wec&AOWNK<#i0Qd=1O$fymLRoUR*%;h@*@v7}wApDl^w*h}!sYq%kw+DKDY)@&A z@9$ULEB3qkR#85`lb8#WZw=@})#kQig9oqy^I$dj&k4jU&^2(M3q{n1AKeGUKPFbr z1^<)aH;VsG@J|B&l>UtU#Ejv3GIqERzYgL@UOAWtW<{p#zy`WyJgpCy8$c_e%wYJL zyGHRRx38)HyjU3y{-4z6)pzb>&Q1pR)B&u01F-|&Gx4EZWK$nkUkOI|(D4UHOXg_- zw{OBf!oWQUn)Pe(=f=nt=zkmdjpO^o8ZZ9o_|4tW1ni+Un9iCW47*-ut$KQOww!;u z`0q)$s6IZO!~9$e_P9X!hqLxu`fpcL|2f^I5d4*a@Dq28;@2271v_N+5HqYZ>x;&O z05*7JT)mUe&%S0@UD)@&8SmQrMtsDfZT;fkdA!r(S=}Oz>iP)w=W508=Rc#nNn7ym z1;42c|8($ALY8#a({%1#IXbWn9-Y|0eDY$_L&j{63?{?AH{);EzcqfydD$@-B`Y3<%IIj7S7rK_N}je^=dEk%JQ4c z!tBdTPE3Tse;oYF>cnrapWq*o)m47X1`~6@(!Y29#>-#8zm&LXrXa(3=7Z)ElaQqj z-#0JJy3Fi(C#Rx(`=VXtJ63E2_bZGCz+QRa{W0e2(m3sI?LOcUBx)~^YCqZ{XEPX)C>G>U4tfqeH8L(3|pQR*zbL1 zT9e~4Tb5p9_G}$y4t`i*4t_Mr9QYvL9C&Ah*}t`q*}S+VYh0M6GxTTSXI)hMpMpIq zD1ImYqJLzbj0}~EpE-aH#VCH_udYEW#`P2zYmi&xSPs_{n6tBj=MY|-XrA;SGA_>y zGtU$?HXm$gYj*!N)_nQ59%lQdXtQZS3*#PC-{iB_sm+ytD*7j`D*k(P&IH2GHT}Eh z5697eQECVIGQAUe#eU2I!yI&%0CP#>%6MWV z@zS!p@+Y1i1b^QuuEF*13CuB zu69dve5k7&Wgb+^s|UB08Dr3u`h@yM0NTj4h7MnHo-4@xmyr7(*4$rpPwsCDZ@2be zRz9V^GnV;;?^Lk%ynzq&K(Aix`mWmW`^152Hoy$CTYVehpD-S1-W^#k#{0^L`V6CN+E z!w+xte;2vu4AmVNEFUOBmrBL>6MK@!O2*N|2=d|Y;oN&A&qv=qKn73lDD zI(+oJAdgv>Yr}8(&@ZuAZE%XUXmX(U!N+Z_sjL<1vjy1R+1IeHt`79fnYdOL{$ci7 z%3f0A*;Zt@ED&Gjm|OFTYBDe%bbo*xXAQsFz+Q`fVBH!N2)kaxN8P$c>sp~QXnv>b zwq=W3&Mtmih7xkR$YA)1Yi?avHNR6C99!u6fh=cL|KQ&PwF!n@ud^n(HNIImHD!h87!i*t?G|p0o+eelJ?B@A64_9%SBhNaJ64EvKgD&%LjLCYnNfc; znj?%*p@*?dq#NqcQFmmX($wms@CSAr9#>hUR^=I+=0B)vvGX%T&#h$kmX*s=^M2E!@N9#m?LhMvz}YB+kd zG~mbP|D(;{s_#;hsKK9lbVK&Lo734x7SIFJ9V_}2$@q?zm^7?*XH94w5Qae{7zOMUF z^?%F%)c1Y)Q?Iy?I>knw*8gYW#ok|2gdS=YYZLiD=CW|Nj;n^x!=S#iJ#`~Ld79+xXpVmUK^B(xO_vO!btA9y7w3L3-0j-y4 z?M-V{%z;JI`bk7yFDcP}OcCd*{Q9S5$iGA7*E1@tfkyjAi!;wP^O71cZ^Ep)qrQ)N z#wqw0_HS;T7x3y|`P==i3hEwK%|>fZ)c&@kgKO1~5<5xBSk?iZV?KI6&i72H6S9A* z=U(*e)EqEs?Oc04)V-~K5AUmh|62H4*`UAtItO$O(q5?6jj+K^oD!04r=6#dsxp?~}{`?&sXn#q2 zGuY~7>O2=!u@@Kfu7q=W*4egu@qPMRM>(eyYyaIE<|j%d=iWNdGsx%c!902v#ngNg z@#U-O_4xN$s_9?(`{>{>7~-6FgWpBpqXb`Ydc3OFL#&I}Irse9F_8R@4zSS*Y*o*B zXL?6*Aw!AfkNCgcr#*yj&p3ZDe2y>v$>FUdKIy_2N~}6AbHc7gA3`6$g@1o|dE>vz z4pl(j9;kyMsjaw}lO?(?Xg%4k!5%^t#@5n=WVc&JRa+XT$~#@rldvN3S1rEpU$;XgxVny7mki3 z-Hh|jUCHrUXuLr!)`w>wgO0N%KTB-1di>cj(x3Bav`7v z3G7EIbU$z>`Nad7Rk_&OT-W{;qg)-GXV-aJT#(ozdmnA~Rq3GQ_3mby(>q6Ocb-RgTUhTN)))x>m&eD;$J5Bg zo&DhY36Yg=J=$Z>t}RJ>o|@hAcwWzN#r(WJ52^g$lh^!63@hh+dR$&_dEGu&^CR*< z!oFqSqO@>xZ*nC2oiOd0eS*F^IL~W-rsrO`J`ej{=ou_q^_(<$&-3f^J z&L^MSYWIe{&pYq&9eGaArA~*kA + + + + + + + + diff --git a/apps/qrcampaign/src/app/404.tsx b/apps/qrcampaign/src/app/404.tsx new file mode 100644 index 0000000..2db4df6 --- /dev/null +++ b/apps/qrcampaign/src/app/404.tsx @@ -0,0 +1,23 @@ +import { Link } from 'react-router-dom'; + +export default function NotFoundPage() { + return ( +
+
+

404

+

+ Page Not Found +

+

+ The page you are looking for doesn't exist or has been moved. +

+ + Go Back Home + +
+
+ ); +} diff --git a/apps/qrcampaign/src/app/admin/campaigns/page.tsx b/apps/qrcampaign/src/app/admin/campaigns/page.tsx new file mode 100644 index 0000000..f71e83b --- /dev/null +++ b/apps/qrcampaign/src/app/admin/campaigns/page.tsx @@ -0,0 +1,262 @@ +import { useState, useEffect } from 'react'; +import { ColumnDef } from '@tanstack/react-table'; +import { DataTable } from '@imphnen-frontend-service/ui/organisms'; +import { + campaignService, + Campaign, + CreateCampaignRequest, +} from '../../features/admin/api/campaign.service'; +import { + DeleteOutlined, + CheckCircleOutlined, + PlusOutlined, +} from '@ant-design/icons'; + +export default function CampaignsPage() { + const [campaigns, setCampaigns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreateModal, setShowCreateModal] = useState(false); + const [createLoading, setCreateLoading] = useState(false); + const [formData, setFormData] = useState({ + name: '', + url: '', + }); + + const fetchCampaigns = async () => { + try { + setLoading(true); + setError(null); + const data = await campaignService.getCampaigns(); + setCampaigns(data); + } catch (err) { + setError('Failed to load campaigns'); + console.error('Error fetching campaigns:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchCampaigns(); + }, []); + + const handleCreateCampaign = async (e: React.FormEvent) => { + e.preventDefault(); + try { + setCreateLoading(true); + await campaignService.createCampaign(formData); + setShowCreateModal(false); + setFormData({ name: '', url: '' }); + await fetchCampaigns(); + } catch (err) { + console.error('Error creating campaign:', err); + alert('Failed to create campaign'); + } finally { + setCreateLoading(false); + } + }; + + const handleActivateCampaign = async (campaignId: string) => { + try { + await campaignService.activateCampaign(campaignId); + await fetchCampaigns(); + } catch (err) { + console.error('Error activating campaign:', err); + alert('Failed to activate campaign'); + } + }; + + const handleDeleteCampaign = async ( + campaignId: string, + campaignName: string + ) => { + if (!confirm(`Are you sure you want to delete "${campaignName}"?`)) { + return; + } + try { + await campaignService.deleteCampaign(campaignId); + await fetchCampaigns(); + } catch (err) { + console.error('Error deleting campaign:', err); + alert('Failed to delete campaign'); + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + cell: ({ row }) => ( +
{row.original.name}
+ ), + }, + { + accessorKey: 'url', + header: 'URL', + cell: ({ row }) => ( + + {row.original.url} + + ), + }, + { + accessorKey: 'is_active', + header: 'Status', + cell: ({ row }) => ( + + {row.original.is_active ? 'Active' : 'Inactive'} + + ), + }, + { + accessorKey: 'created_at', + header: 'Created At', + cell: ({ row }) => ( + + {new Date(row.original.created_at).toLocaleDateString()} + + ), + }, + { + id: 'actions', + header: 'Actions', + cell: ({ row }) => ( +
+ {!row.original.is_active && ( + + )} + +
+ ), + }, + ]; + + if (loading) { + return ( +
+
+
Loading campaigns...
+
+
+ ); + } + + if (error) { + return ( +
+
+ {error} +
+
+ ); + } + + return ( +
+
+
+

+ Campaign Management +

+

Manage your QR campaigns

+
+ +
+ + + + {/* Create Campaign Modal */} + {showCreateModal && ( +
+
+

Create New Campaign

+
+
+ + + setFormData({ ...formData, name: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="IMPHNEN Promo Campaign" + required + /> +
+
+ + + setFormData({ ...formData, url: e.target.value }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="https://imphnen.dev/promo" + required + /> +
+
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/apps/qrcampaign/src/app/admin/layout.tsx b/apps/qrcampaign/src/app/admin/layout.tsx new file mode 100644 index 0000000..8adf10d --- /dev/null +++ b/apps/qrcampaign/src/app/admin/layout.tsx @@ -0,0 +1,10 @@ +import { Outlet } from 'react-router-dom'; +import { RequireAdmin } from '../features/admin/components/RequireAdmin'; + +export default function AdminLayout() { + return ( + + + + ); +} diff --git a/apps/qrcampaign/src/app/admin/page.tsx b/apps/qrcampaign/src/app/admin/page.tsx new file mode 100644 index 0000000..f9413af --- /dev/null +++ b/apps/qrcampaign/src/app/admin/page.tsx @@ -0,0 +1,3 @@ +export default function AdminPage() { + return
Select a menu item from the sidebar.
; +} diff --git a/apps/qrcampaign/src/app/admin/users/page.tsx b/apps/qrcampaign/src/app/admin/users/page.tsx new file mode 100644 index 0000000..7dc434f --- /dev/null +++ b/apps/qrcampaign/src/app/admin/users/page.tsx @@ -0,0 +1,199 @@ +import { useState, useEffect } from 'react'; +import { ColumnDef } from '@tanstack/react-table'; +import { DataTable } from '@imphnen-frontend-service/ui/organisms'; +import { userService, User } from '../../features/admin/api/user.service'; +import { DeleteOutlined, EditOutlined } from '@ant-design/icons'; + +export default function UsersPage() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editingUserId, setEditingUserId] = useState(null); + const [selectedRole, setSelectedRole] = useState(''); + + const fetchUsers = async () => { + try { + setLoading(true); + setError(null); + const data = await userService.getUsers(); + setUsers(data); + } catch (err) { + setError('Failed to load users'); + console.error('Error fetching users:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchUsers(); + }, []); + + const handleUpdateRole = async (userId: string, currentRole: string) => { + if (editingUserId === userId) { + // Save the role + try { + await userService.updateUserRole(userId, selectedRole); + setEditingUserId(null); + setSelectedRole(''); + await fetchUsers(); + } catch (err) { + console.error('Error updating user role:', err); + alert('Failed to update user role'); + } + } else { + // Start editing + setEditingUserId(userId); + setSelectedRole(currentRole); + } + }; + + const handleDeleteUser = async (userId: string, userName: string) => { + if (!confirm(`Are you sure you want to delete user "${userName}"?`)) { + return; + } + try { + await userService.deleteUser(userId); + await fetchUsers(); + } catch (err) { + console.error('Error deleting user:', err); + alert('Failed to delete user'); + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + cell: ({ row }) => ( +
{row.original.name}
+ ), + }, + { + accessorKey: 'email', + header: 'Email', + cell: ({ row }) => ( +
{row.original.email}
+ ), + }, + { + accessorKey: 'role', + header: 'Role', + cell: ({ row }) => { + const isEditing = editingUserId === row.original.id; + return ( +
+ {isEditing ? ( + + ) : ( + + {row.original.role.charAt(0).toUpperCase() + + row.original.role.slice(1)} + + )} +
+ ); + }, + }, + { + accessorKey: 'created_at', + header: 'Created At', + cell: ({ row }) => ( + + {new Date(row.original.created_at).toLocaleDateString()} + + ), + }, + { + id: 'actions', + header: 'Actions', + cell: ({ row }) => { + const isEditing = editingUserId === row.original.id; + return ( +
+ + {isEditing && ( + + )} + {!isEditing && ( + + )} +
+ ); + }, + }, + ]; + + if (loading) { + return ( +
+
+
Loading users...
+
+
+ ); + } + + if (error) { + return ( +
+
+ {error} +
+
+ ); + } + + return ( +
+
+

User Management

+

Manage users and their roles

+
+ + +
+ ); +} diff --git a/apps/qrcampaign/src/app/app.module.css b/apps/qrcampaign/src/app/app.module.css deleted file mode 100644 index 7b88fba..0000000 --- a/apps/qrcampaign/src/app/app.module.css +++ /dev/null @@ -1 +0,0 @@ -/* Your styles goes here. */ diff --git a/apps/qrcampaign/src/app/app.spec.tsx b/apps/qrcampaign/src/app/app.spec.tsx deleted file mode 100644 index b95ead2..0000000 --- a/apps/qrcampaign/src/app/app.spec.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { render } from '@testing-library/react'; -import { BrowserRouter } from 'react-router-dom'; - -import App from './app'; - -describe('App', () => { - it('should render successfully', () => { - const { baseElement } = render( - - - - ); - expect(baseElement).toBeTruthy(); - }); - - it('should have a greeting as the title', () => { - const { getAllByText } = render( - - - - ); - expect( - getAllByText(new RegExp('Welcome qrcampaign', 'gi')).length > 0 - ).toBeTruthy(); - }); -}); diff --git a/apps/qrcampaign/src/app/auth/callback/page.tsx b/apps/qrcampaign/src/app/auth/callback/page.tsx new file mode 100644 index 0000000..6e37273 --- /dev/null +++ b/apps/qrcampaign/src/app/auth/callback/page.tsx @@ -0,0 +1,183 @@ +import { FC, ReactElement, useEffect, useState, useRef } from 'react'; +import { useNavigate } from 'react-router'; +import { useGitHubCallback } from '@imphnen-frontend-service/service'; +import { toast } from 'sonner'; + +const CallbackPage: FC = (): ReactElement => { + const navigate = useNavigate(); + const { mutateAsync: exchangeGitHubCode } = useGitHubCallback(); + const [isProcessing, setIsProcessing] = useState(true); + const [error, setError] = useState(null); + const hasRunRef = useRef(false); + + useEffect(() => { + const handleCallback = async () => { + if (hasRunRef.current) { + return; + } + hasRunRef.current = true; + + try { + // Check URL hash for Supabase email confirmation callback + const hashParams = new URLSearchParams( + globalThis.location.hash.substring(1) + ); + const urlParams = new URLSearchParams(globalThis.location.search); + + const type = hashParams.get('type') || urlParams.get('type'); + const accessToken = + hashParams.get('access_token') || urlParams.get('access_token'); + + // Debug: log what we received + console.log('[Callback] Params:', { + type, + accessToken: !!accessToken, + hash: globalThis.location.hash, + search: globalThis.location.search, + }); + + // Handle Supabase email callbacks (has access_token in hash or query) + // This includes: signup confirmation, email confirmation, password recovery + if (accessToken) { + setIsProcessing(false); + + // Password recovery - type is 'recovery' or we have access_token from reset email + if (type === 'recovery' || type === 'magiclink') { + toast.success('Email verified! Please set your new password.'); + navigate('/auth/reset-password?access_token=' + accessToken); + return; + } + + // Signup/Email confirmation + if (type === 'signup' || type === 'email_confirmation') { + toast.success( + 'Email verified successfully! Please log in to continue.' + ); + navigate('/auth/login'); + return; + } + + // If we have access_token but unknown type, assume it's password recovery + // (Supabase sometimes sends without explicit type) + toast.success('Email verified! Please set your new password.'); + navigate('/auth/reset-password?access_token=' + accessToken); + return; + } + + // Get the code from URL query params (GitHub OAuth) + const code = urlParams.get('code'); + + if (!code) { + throw new Error('No authorization code received'); + } + + // Exchange the code for tokens using backend API (GitHub OAuth) + const result = await exchangeGitHubCode({ code }); + + toast.success('Login successful!'); + setIsProcessing(false); + + // Check if user has completed onboarding (has location) + if (result.user.location) { + globalThis.location.replace('/dashboard'); + } else { + globalThis.location.replace('/onboarding/user'); + } + } catch (err) { + console.error('[Callback] Error:', err); + setError((err as Error).message); + setIsProcessing(false); + toast.error('An error occurred during login'); + + setTimeout(() => { + navigate('/auth/login'); + }, 3000); + } + }; + + handleCallback(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (error) { + // Check if error is related to private email + const isPrivateEmailError = + error.toLowerCase().includes('failed to create user') || + error.toLowerCase().includes('email') || + error.toLowerCase().includes('user record'); + + return ( +
+
+
+
⚠️
+

+ GitHub Login Failed +

+

{error}

+
+ + {isPrivateEmailError && ( +
+

+ Is your GitHub email set to private? +

+

+ GitHub login requires a public email address. Please follow + these steps: +

+
    +
  1. + Go to{' '} + + GitHub Email Settings + +
  2. +
  3. Uncheck "Keep my email addresses private"
  4. +
  5. + Or go to{' '} + + Profile Settings + {' '} + and set a public email +
  6. +
  7. Try signing in with GitHub again
  8. +
+

+ Alternatively, you can sign up using email and password instead. +

+
+ )} + +

+ Redirecting to login page in 3 seconds... +

+
+
+ ); + } + + return ( +
+
+
+

+ Completing login... +

+

Please wait

+
+
+ ); +}; + +export default CallbackPage; diff --git a/apps/qrcampaign/src/app/auth/forgot-password/page.tsx b/apps/qrcampaign/src/app/auth/forgot-password/page.tsx new file mode 100644 index 0000000..773091f --- /dev/null +++ b/apps/qrcampaign/src/app/auth/forgot-password/page.tsx @@ -0,0 +1,125 @@ +import { useState } from 'react'; +import { useForgotPassword } from '@imphnen-frontend-service/service'; +import { Link, useNavigate } from 'react-router'; +import { toast } from 'sonner'; +import { Icon } from '@iconify/react'; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(''); + const [emailSent, setEmailSent] = useState(false); + const navigate = useNavigate(); + const forgotPasswordMutation = useForgotPassword(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!email) { + toast.error('Please enter your email'); + return; + } + + try { + await forgotPasswordMutation.mutateAsync({ email }); + + setEmailSent(true); + toast.success('Password reset email sent! Check your inbox.'); + } catch (err) { + toast.error((err as Error).message || 'Failed to send reset email'); + } + }; + + if (emailSent) { + return ( +
+
+
+
+ +
+

+ Check Your Email +

+

+ We've sent a password reset link to {email} +

+
+ +
+

+ Click the link in the email to reset your password. The link will + expire in 1 hour. +

+ + + + + + +
+
+
+ ); + } + + return ( +
+
+
+ +
+
+

+ Forgot Password? +

+

+ No worries, we'll send you reset instructions +

+
+ +
+
+ + setEmail(e.target.value)} + placeholder="your@email.com" + disabled={forgotPasswordMutation.isPending} + className="bg-white text-gray-900 w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed" + required + /> +
+ + +
+
+
+ ); +} diff --git a/apps/qrcampaign/src/app/auth/login/page.tsx b/apps/qrcampaign/src/app/auth/login/page.tsx new file mode 100644 index 0000000..96746ca --- /dev/null +++ b/apps/qrcampaign/src/app/auth/login/page.tsx @@ -0,0 +1,234 @@ +import { useState, useEffect } from 'react'; +import { GithubOutlined } from '@ant-design/icons'; +import { useNavigate, Link } from 'react-router'; +import { toast } from 'sonner'; +import { Icon } from '@iconify/react'; +import { useAuthStore } from '../../features/auth/store/auth.store'; + +export default function LoginPage() { + const navigate = useNavigate(); + const login = useAuthStore((state) => state.login); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + + const [isGithubLoading, setIsGithubLoading] = useState(false); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [showPassword, setShowPassword] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (isAuthenticated) { + navigate('/'); + } + }, [isAuthenticated, navigate]); + + // Check for password reset tokens in URL and redirect to reset-password page + useEffect(() => { + const hashParams = new URLSearchParams( + globalThis.location.hash.substring(1) + ); + const urlParams = new URLSearchParams(globalThis.location.search); + + const accessToken = + hashParams.get('access_token') || urlParams.get('access_token'); + const type = hashParams.get('type') || urlParams.get('type'); + + // If we have an access_token, this is likely a password reset redirect that landed on the wrong page + if (accessToken) { + console.log( + '[Login] Detected access_token, redirecting to reset-password page' + ); + + // Check if it's a password recovery + if (type === 'recovery' || type === 'magiclink' || !type) { + toast.info('Redirecting to password reset...'); + navigate('/auth/reset-password?access_token=' + accessToken); + } + } + }, [navigate]); + + const handleEmailLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + if (!email || !password) { + setError('Please enter both email and password'); + return; + } + + setIsSubmitting(true); + try { + const success = await login(email, password); + + if (success) { + toast.success('Login successful!'); + navigate('/'); + } else { + setError('Login failed. Please check your credentials.'); + } + } catch (err) { + console.error('[Login] Email login failed:', err); + setError('Login failed. Please try again.'); + } finally { + setIsSubmitting(false); + } + }; + + const handleGithubLogin = async () => { + // TODO: Implement GitHub login with new auth service if needed + toast.info('GitHub login coming soon'); + }; + + return ( +
+
+ {/*
+ +
*/} + +
+

+ Welcome Back +

+

Sign in to continue to IMPHNEN

+
+ + {error && ( +
+

{error}

+
+ )} + + {/* Traditional Login Form */} +
+
+ + setEmail(e.target.value)} + placeholder="yourname@example.com" + disabled={isSubmitting} + className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed" + required + /> +
+ +
+
+ + + Forgot password? + +
+
+ setPassword(e.target.value)} + placeholder="••••••••" + disabled={isSubmitting} + className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed pr-10" + required + /> + +
+
+ + +
+ +
+
+
+
+
+ + Or continue with + +
+
+ + + +

+ Make sure your GitHub email is{' '} + + set to public + {' '} + for GitHub sign in to work. +

+ +
+

+ Don't have an account?{' '} + + Sign up + +

+
+ +
+

+ By signing in, you agree to our Terms of Service and Privacy Policy +

+
+
+
+ ); +} diff --git a/apps/qrcampaign/src/app/auth/reset-password/page.tsx b/apps/qrcampaign/src/app/auth/reset-password/page.tsx new file mode 100644 index 0000000..6fe9091 --- /dev/null +++ b/apps/qrcampaign/src/app/auth/reset-password/page.tsx @@ -0,0 +1,171 @@ +import { useState, useEffect } from 'react'; +import { + useResetPassword, + useAuthStore, +} from '@imphnen-frontend-service/service'; +import { useNavigate } from 'react-router'; +import { toast } from 'sonner'; +import { Icon } from '@iconify/react'; + +export default function ResetPasswordPage() { + const navigate = useNavigate(); + const { clearSession } = useAuthStore(); + const resetPasswordMutation = useResetPassword(); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [accessToken, setAccessToken] = useState(null); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + useEffect(() => { + // Get the access_token from URL hash (Supabase sends it as hash fragment) + // or from query params (when redirected from callback page) + const hashParams = new URLSearchParams( + globalThis.location.hash.substring(1) + ); + const queryParams = new URLSearchParams(globalThis.location.search); + const token = + hashParams.get('access_token') || queryParams.get('access_token'); + + if (token) { + setAccessToken(token); + } else { + toast.error('Invalid or expired reset link'); + setTimeout(() => navigate('/auth/forgot-password'), 2000); + } + }, [navigate]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (password !== confirmPassword) { + toast.error('Passwords do not match'); + return; + } + + if (password.length < 6) { + toast.error('Password must be at least 6 characters'); + return; + } + + if (!accessToken) { + toast.error('Invalid reset token'); + return; + } + + try { + await resetPasswordMutation.mutateAsync({ + access_token: accessToken, + new_password: password, + }); + + toast.success('Password updated successfully!'); + + // Clear session and redirect to login + clearSession(); + navigate('/auth/login'); + } catch (err) { + toast.error((err as Error).message || 'Failed to reset password'); + } + }; + + if (!accessToken) { + return ( +
+
+
+

Verifying reset link...

+
+
+ ); + } + + return ( +
+
+
+

+ Set New Password +

+

Enter your new password below

+
+ +
+
+ +
+ setPassword(e.target.value)} + placeholder="••••••••" + disabled={resetPasswordMutation.isPending} + className="w-full px-4 py-2.5 pr-12 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white text-gray-900" + required + minLength={6} + /> + +
+
+ +
+ +
+ setConfirmPassword(e.target.value)} + placeholder="••••••••" + disabled={resetPasswordMutation.isPending} + className="w-full px-4 py-2.5 pr-12 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white text-gray-900" + required + minLength={6} + /> + +
+
+ + +
+
+
+ ); +} diff --git a/apps/qrcampaign/src/app/auth/signup/page.tsx b/apps/qrcampaign/src/app/auth/signup/page.tsx new file mode 100644 index 0000000..ffd5100 --- /dev/null +++ b/apps/qrcampaign/src/app/auth/signup/page.tsx @@ -0,0 +1,305 @@ +import { useState } from 'react'; +import { useGitHubAuth } from '@imphnen-frontend-service/service'; +import { GithubOutlined } from '@ant-design/icons'; +import { useNavigate, Link } from 'react-router'; +import { toast } from 'sonner'; +import { Icon } from '@iconify/react'; +import { useAuthStore } from '../../features/auth/store/auth.store'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; + +const signupSchema = z + .object({ + fullname: z + .string() + .min(1, 'Full name is required') + .min(2, 'Full name must be at least 2 characters'), + email: z + .string() + .min(1, 'Email is required') + .email('Please enter a valid email address'), + password: z + .string() + .min(1, 'Password is required') + .min(6, 'Password must be at least 6 characters'), + confirmPassword: z.string().min(1, 'Please confirm your password'), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'], + }); + +type SignupFormData = z.infer; + +export default function SignupPage() { + const navigate = useNavigate(); + const registerUser = useAuthStore((state) => state.register); + + const { signInWithGitHub } = useGitHubAuth(); + const [isGithubLoading, setIsGithubLoading] = useState(false); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const { + register, + handleSubmit, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(signupSchema), + mode: 'onChange', + }); + + const onSubmit = async (data: SignupFormData) => { + setError(null); + setIsSubmitting(true); + + try { + await registerUser(data.fullname, data.email, data.password); + toast.success('Registration successful! Redirecting...'); + navigate('/'); + } catch (err: any) { + console.error('[Signup] Email signup failed:', err); + // Construct a user-friendly error message + const errorMessage = + err.response?.data?.message || err.message || 'Signup failed'; + setError(errorMessage); + } finally { + setIsSubmitting(false); + } + }; + + const handleGithubLogin = async () => { + try { + setIsGithubLoading(true); + + const result = await signInWithGitHub(); + + if (result?.url) { + globalThis.location.href = result.url; + } else { + setIsGithubLoading(false); + setError('Failed to get GitHub OAuth URL'); + } + } catch (err) { + console.error('[Signup] GitHub login failed:', err); + setError((err as Error).message || 'GitHub login failed'); + setIsGithubLoading(false); + } + }; + + const inputBaseClass = + 'w-full px-4 py-2.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed'; + const inputErrorClass = 'border-red-500'; + const inputNormalClass = 'border-gray-300'; + + return ( +
+
+
+ +
+ +
+

+ Create Account +

+

Join IMPHNEN community

+
+ + {error && ( +
+

{error}

+
+ )} + +
+
+ + + {errors.fullname && ( +

+ {errors.fullname.message} +

+ )} +
+ +
+ + + {errors.email && ( +

+ {errors.email.message} +

+ )} +
+ +
+ +
+ + +
+ {errors.password && ( +

+ {errors.password.message} +

+ )} +
+ +
+ +
+ + +
+ {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )} +
+ + +
+ +
+
+ OR +
+
+ + + +

+ Make sure your GitHub email is{' '} + + set to public + {' '} + for GitHub sign up to work. +

+ +
+

+ Already have an account?{' '} + + Sign in + +

+
+ +
+

+ By signing up, you agree to our Terms of Service and Privacy Policy +

+
+
+
+ ); +} diff --git a/apps/qrcampaign/src/app/error.tsx b/apps/qrcampaign/src/app/error.tsx new file mode 100644 index 0000000..3859b00 --- /dev/null +++ b/apps/qrcampaign/src/app/error.tsx @@ -0,0 +1,29 @@ +import { useRouteError, isRouteErrorResponse } from 'react-router-dom'; + +export default function ErrorPage() { + const error = useRouteError(); + let errorMessage: string; + + if (isRouteErrorResponse(error)) { + errorMessage = error.statusText; + } else if (error instanceof Error) { + errorMessage = error.message; + } else if (typeof error === 'string') { + errorMessage = error; + } else { + console.error(error); + errorMessage = 'Unknown error'; + } + + return ( +
+
+

Oops!

+

+ Sorry, an unexpected error has occurred. +

+

{errorMessage}

+
+
+ ); +} diff --git a/apps/qrcampaign/src/app/features/admin/api/campaign.service.ts b/apps/qrcampaign/src/app/features/admin/api/campaign.service.ts new file mode 100644 index 0000000..7c02970 --- /dev/null +++ b/apps/qrcampaign/src/app/features/admin/api/campaign.service.ts @@ -0,0 +1,56 @@ +import { api } from '../../auth/api/auth.service'; + +// Types +export interface Campaign { + id: string; + name: string; + url: string; + is_active: boolean; + created_at: string; + updated_at: string; +} + +export interface CreateCampaignRequest { + name: string; + url: string; +} + +interface CampaignsResponse { + success: boolean; + message: string; + data: Campaign[]; +} + +interface CampaignResponse { + success: boolean; + message: string; + data: Campaign; +} + +interface DeleteResponse { + success: boolean; + message: string; +} + +export const campaignService = { + getCampaigns: async (): Promise => { + const response = await api.get('/campaigns'); + return response.data.data; + }, + + createCampaign: async (data: CreateCampaignRequest): Promise => { + const response = await api.post('/campaigns', data); + return response.data.data; + }, + + activateCampaign: async (campaignId: string): Promise => { + const response = await api.put( + `/campaigns/${campaignId}/activate` + ); + return response.data.data; + }, + + deleteCampaign: async (campaignId: string): Promise => { + await api.delete(`/campaigns/${campaignId}`); + }, +}; diff --git a/apps/qrcampaign/src/app/features/admin/api/user.service.ts b/apps/qrcampaign/src/app/features/admin/api/user.service.ts new file mode 100644 index 0000000..a7d8b4e --- /dev/null +++ b/apps/qrcampaign/src/app/features/admin/api/user.service.ts @@ -0,0 +1,53 @@ +import { api } from '../../auth/api/auth.service'; + +// Types +export interface User { + id: string; + email: string; + name: string; + role: string; + created_at: string; + updated_at: string; +} + +export interface UpdateUserRoleRequest { + role: string; +} + +interface UsersResponse { + success: boolean; + message: string; + data: User[]; +} + +interface UserResponse { + success: boolean; + message: string; + data: User; +} + +interface DeleteResponse { + success: boolean; + message: string; +} + +export const userService = { + getUsers: async (): Promise => { + const response = await api.get('/users'); + return response.data.data; + }, + + updateUserRole: async ( + userId: string, + role: string + ): Promise => { + const response = await api.put(`/users/${userId}/role`, { + role, + }); + return response.data.data; + }, + + deleteUser: async (userId: string): Promise => { + await api.delete(`/users/${userId}`); + }, +}; diff --git a/apps/qrcampaign/src/app/features/admin/components/RequireAdmin.tsx b/apps/qrcampaign/src/app/features/admin/components/RequireAdmin.tsx new file mode 100644 index 0000000..b0e03cf --- /dev/null +++ b/apps/qrcampaign/src/app/features/admin/components/RequireAdmin.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuthStore } from '../../auth/store/auth.store'; + +interface RequireAdminProps { + children: JSX.Element; +} + +export const RequireAdmin = ({ children }: RequireAdminProps) => { + const user = useAuthStore((state) => state.user); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const location = useLocation(); + + if (!isAuthenticated) { + return ; + } + + // Check if user has admin role + // user.role is now an object { id, name, permissions } + const userRole = user?.role?.name; + if (userRole !== 'Admin' && userRole !== 'Super Admin') { + // Redirect non-admins to home + return ; + } + + return children; +}; diff --git a/apps/qrcampaign/src/app/features/admin/pages/AdminDashboard.tsx b/apps/qrcampaign/src/app/features/admin/pages/AdminDashboard.tsx new file mode 100644 index 0000000..a9f79b5 --- /dev/null +++ b/apps/qrcampaign/src/app/features/admin/pages/AdminDashboard.tsx @@ -0,0 +1,64 @@ +import { Outlet, Link, useLocation } from 'react-router-dom'; +import { useAuthStore } from '../../auth/store/auth.store'; + +export const AdminDashboard = () => { + const logout = useAuthStore((state) => state.logout); + const location = useLocation(); + + const isActive = (path: string) => location.pathname.startsWith(path); + + return ( +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ +
+
+ ); +}; diff --git a/apps/qrcampaign/src/app/features/admin/pages/CampaignManagement.tsx b/apps/qrcampaign/src/app/features/admin/pages/CampaignManagement.tsx new file mode 100644 index 0000000..4c15e43 --- /dev/null +++ b/apps/qrcampaign/src/app/features/admin/pages/CampaignManagement.tsx @@ -0,0 +1,237 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; + +interface Campaign { + id: string; + name: string; + url: string; + image_url?: string; // QR code image URL if we want to show it + is_active: boolean; + created_at: string; +} + +interface CreateCampaignInputs { + name: string; + url: string; +} + +export const CampaignManagement = () => { + const queryClient = useQueryClient(); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm(); + + // Fetch Campaigns + const { + data: campaigns, + isLoading, + isError, + } = useQuery({ + queryKey: ['campaigns'], + queryFn: async () => { + const res = await axios.get('http://localhost:8080/api/v1/campaigns'); + return res.data.data as Campaign[]; + }, + }); + + // Create Campaign + const createMutation = useMutation({ + mutationFn: async (data: CreateCampaignInputs) => { + await axios.post('http://localhost:8080/api/v1/campaigns', data); + }, + onSuccess: () => { + toast.success('Campaign created successfully'); + queryClient.invalidateQueries({ queryKey: ['campaigns'] }); + setIsCreateModalOpen(false); + reset(); + }, + onError: (error: any) => { + toast.error(error.response?.data?.message || 'Failed to create campaign'); + }, + }); + + // Activate Campaign + const activateMutation = useMutation({ + mutationFn: async (id: string) => { + await axios.put(`http://localhost:8080/api/v1/campaigns/${id}/activate`); + }, + onSuccess: () => { + toast.success('Campaign activated'); + queryClient.invalidateQueries({ queryKey: ['campaigns'] }); + // Also invalidate active QR for the main app + queryClient.invalidateQueries({ queryKey: ['active-campaign-qr'] }); + }, + onError: () => toast.error('Failed to activate campaign'), + }); + + // Delete Campaign + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + await axios.delete(`http://localhost:8080/api/v1/campaigns/${id}`); + }, + onSuccess: () => { + toast.success('Campaign deleted'); + queryClient.invalidateQueries({ queryKey: ['campaigns'] }); + }, + onError: () => toast.error('Failed to delete campaign'), + }); + + const onCreateSubmit = (data: CreateCampaignInputs) => { + createMutation.mutate(data); + }; + + if (isLoading) return
Loading campaigns...
; + if (isError) return
Error loading campaigns.
; + + return ( +
+
+

Campaigns

+ +
+ +
+ + + + + + + + + + + {campaigns?.map((campaign) => ( + + + + + + + ))} + {campaigns?.length === 0 && ( + + + + )} + +
NameURLStatusActions
+ {campaign.name} + + {campaign.url} + + {campaign.is_active ? ( + + Active + + ) : ( + + Inactive + + )} + + {!campaign.is_active && ( + + )} + +
+ No campaigns found. Create one to get started. +
+
+ + {/* Basic Create Modal */} + {isCreateModalOpen && ( +
+
+

+ Create New Campaign +

+
+
+ + + {errors.name && ( +

+ {errors.name.message} +

+ )} +
+
+ + + {errors.url && ( +

+ {errors.url.message} +

+ )} +
+
+ + +
+
+
+
+ )} +
+ ); +}; diff --git a/apps/qrcampaign/src/app/features/admin/pages/UserManagement.tsx b/apps/qrcampaign/src/app/features/admin/pages/UserManagement.tsx new file mode 100644 index 0000000..471f8c1 --- /dev/null +++ b/apps/qrcampaign/src/app/features/admin/pages/UserManagement.tsx @@ -0,0 +1,121 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import axios from 'axios'; +import { toast } from 'sonner'; + +interface User { + id: string; + name: string; + email: string; + role: string; + created_at: string; +} + +export const UserManagement = () => { + const queryClient = useQueryClient(); + + // Fetch Users + const { + data: users, + isLoading, + isError, + } = useQuery({ + queryKey: ['users'], + queryFn: async () => { + const res = await axios.get('http://localhost:8080/api/v1/users'); + return res.data.data as User[]; + }, + }); + + // Update Role + const updateRoleMutation = useMutation({ + mutationFn: async ({ id, role }: { id: string; role: string }) => { + await axios.put(`http://localhost:8080/api/v1/users/${id}/role`, { + role, + }); + }, + onSuccess: () => { + toast.success('User role updated'); + queryClient.invalidateQueries({ queryKey: ['users'] }); + }, + onError: () => toast.error('Failed to update user role'), + }); + + // Delete User + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + await axios.delete(`http://localhost:8080/api/v1/users/${id}`); + }, + onSuccess: () => { + toast.success('User deleted'); + queryClient.invalidateQueries({ queryKey: ['users'] }); + }, + onError: () => toast.error('Failed to delete user'), + }); + + if (isLoading) return
Loading users...
; + if (isError) return
Error loading users.
; + + return ( +
+

Users

+ +
+ + + + + + + + + + + {users?.map((user) => ( + + + + + + + ))} + +
NameEmailRoleActions
+ {user.name} + + {user.email} + + + + +
+
+
+ ); +}; diff --git a/apps/qrcampaign/src/app/features/auth/api/auth.service.ts b/apps/qrcampaign/src/app/features/auth/api/auth.service.ts new file mode 100644 index 0000000..9c0bcb8 --- /dev/null +++ b/apps/qrcampaign/src/app/features/auth/api/auth.service.ts @@ -0,0 +1,129 @@ +import axios from 'axios'; + +// Define the base URL for the API +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1'; + +// Create a configured axios instance +export const api = axios.create({ + baseURL: API_URL, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Add interceptor to add token to requests +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers['Authorization'] = `Bearer ${token}`; + } + return config; + }, + (error) => Promise.reject(error) +); + +// Types +export interface LoginRequest { + email: string; + password: string; +} + +export interface RegisterRequest { + name: string; + email: string; + password: string; +} + +// Backend user response +interface BackendUser { + id: string; + email: string; + name: string; + role: string; + provider: string; + created_at: string; + updated_at: string; +} + +// Frontend user type +export interface User { + id: string; + email: string; + fullname: string; + role?: { + id: string; + name: string; + permissions: string[]; + }; +} + +// Backend auth response +interface BackendAuthResponse { + success: boolean; + message: string; + data: { + tokens: { + access_token: string; + refresh_token: string; + }; + user: BackendUser; + }; +} + +export interface AuthResponse { + success: boolean; + message: string; + data: { + tokens: { + access_token: string; + refresh_token: string; + }; + user: User; + }; +} + +// Helper to transform backend user to frontend user +const transformUser = (backendUser: BackendUser): User => { + return { + id: backendUser.id, + email: backendUser.email, + fullname: backendUser.name, + role: { + id: '', + name: backendUser.role === 'admin' ? 'Admin' : backendUser.role === 'user' ? 'User' : 'User', + permissions: [], + }, + }; +}; + +export const authService = { + login: async (data: LoginRequest): Promise => { + const response = await api.post('/auth/login', data); + return { + success: response.data.success, + message: response.data.message, + data: { + tokens: response.data.data.tokens, + user: transformUser(response.data.data.user), + }, + }; + }, + + register: async (data: RegisterRequest): Promise => { + const response = await api.post('/auth/register', data); + return { + success: response.data.success, + message: response.data.message, + data: { + tokens: response.data.data.tokens, + user: transformUser(response.data.data.user), + }, + }; + }, + + getProfile: async (): Promise => { + const response = await api.get('/users/me'); + return response.data; + }, +}; diff --git a/apps/qrcampaign/src/app/features/auth/components/RequireAuth.tsx b/apps/qrcampaign/src/app/features/auth/components/RequireAuth.tsx new file mode 100644 index 0000000..9291acb --- /dev/null +++ b/apps/qrcampaign/src/app/features/auth/components/RequireAuth.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuthStore } from '../../../features/auth/store/auth.store'; + +interface RequireAuthProps { + children: JSX.Element; +} + +export const RequireAuth = ({ children }: RequireAuthProps) => { + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const location = useLocation(); + + if (!isAuthenticated) { + // Redirect them to the /login page, but save the current location they were + // trying to go to when they were redirected. This allows us to send them + // along to that page after they login, which is a nicer user experience. + return ; + } + + return children; +}; diff --git a/apps/qrcampaign/src/app/features/auth/pages/LoginPage.tsx b/apps/qrcampaign/src/app/features/auth/pages/LoginPage.tsx new file mode 100644 index 0000000..7594382 --- /dev/null +++ b/apps/qrcampaign/src/app/features/auth/pages/LoginPage.tsx @@ -0,0 +1,102 @@ +import React, { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { useAuthStore } from '../store/auth.store'; +import { useNavigate, useLocation } from 'react-router-dom'; + +// Reusing UI components logic or standard HTML for now to keep it simple and dependency-free if UI lib issues arise +// But user mentioned shared UI libs, let's try to use standard Tailwind first to ensure speed. + +interface LoginFormInputs { + email: string; + pass: string; +} + +export const LoginPage = () => { + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm(); + const login = useAuthStore((state) => state.login); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const navigate = useNavigate(); + const location = useLocation(); + + const from = location.state?.from?.pathname || '/'; + + useEffect(() => { + if (isAuthenticated) { + navigate(from, { replace: true }); + } + }, [isAuthenticated, navigate, from]); + + const onSubmit = async (data: LoginFormInputs) => { + const success = await login(data.email, data.pass); + if (success) { + // Get user from store to check role + const user = useAuthStore.getState().user; + const userRole = user?.role?.name; + + // Redirect admin to admin dashboard + if (userRole === 'Admin' || userRole === 'Super Admin') { + navigate('/admin/campaigns', { replace: true }); + } else { + navigate(from, { replace: true }); + } + } + }; + + return ( +
+
+

+ Login +

+ +
+
+ + + {errors.email && ( +

+ {errors.email.message} +

+ )} +
+ +
+ + + {errors.pass && ( +

{errors.pass.message}

+ )} +
+ + +
+ +
+

Demo credentials available in backend seeder.

+
+
+
+ ); +}; diff --git a/apps/qrcampaign/src/app/features/auth/store/auth.store.ts b/apps/qrcampaign/src/app/features/auth/store/auth.store.ts new file mode 100644 index 0000000..49b891d --- /dev/null +++ b/apps/qrcampaign/src/app/features/auth/store/auth.store.ts @@ -0,0 +1,84 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { authService, User } from '../api/auth.service'; + +interface AuthState { + user: User | null; + token: string | null; + isAuthenticated: boolean; + login: (email: string, pass: string) => Promise; + register: (name: string, email: string, pass: string) => Promise; + logout: () => void; + setUser: (user: User) => void; +} + +export const useAuthStore = create()( + persist( + (set) => ({ + user: null, + token: null, + isAuthenticated: false, + + login: async (email, password) => { + try { + const response = await authService.login({ email, password }); + + const token = response.data.tokens.access_token; + const refreshToken = response.data.tokens.refresh_token; + + localStorage.setItem('token', token); + localStorage.setItem('refreshToken', refreshToken); + + set({ + user: response.data.user, + token: token, + isAuthenticated: true + }); + + return true; + } catch (error) { + console.error('Login failed:', error); + return false; + } + }, + + register: async (name, email, password) => { + try { + const response = await authService.register({ name, email, password }); + + const token = response.data.tokens.access_token; + const refreshToken = response.data.tokens.refresh_token; + + localStorage.setItem('token', token); + localStorage.setItem('refreshToken', refreshToken); + + set({ + user: response.data.user, + token: token, + isAuthenticated: true + }); + + return true; + } catch (error) { + console.error('Registration failed:', error); + throw error; + } + }, + + logout: () => { + localStorage.removeItem('token'); + set({ user: null, token: null, isAuthenticated: false }); + }, + + setUser: (user) => set({ user }), + }), + { + name: 'auth-storage', // name of the item in the storage (must be unique) + partialize: (state) => ({ + user: state.user, + token: state.token, + isAuthenticated: state.isAuthenticated + }), + } + ) +); diff --git a/apps/qrcampaign/src/app/features/campaign/api/useActiveCampaignQR.ts b/apps/qrcampaign/src/app/features/campaign/api/useActiveCampaignQR.ts new file mode 100644 index 0000000..658a0c7 --- /dev/null +++ b/apps/qrcampaign/src/app/features/campaign/api/useActiveCampaignQR.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; + +export const useActiveCampaignQR = () => { + return useQuery({ + queryKey: ['active-campaign-qr'], + queryFn: async () => { + // Assuming backend is running on localhost:8080 + // In production, this should be an env var or relative path if proxied + const response = await axios.get('http://localhost:8080/api/v1/campaigns/active/qr', { + responseType: 'blob', + }); + return URL.createObjectURL(response.data); + }, + staleTime: 1000 * 60 * 5, // 5 minutes + }); +}; diff --git a/apps/qrcampaign/src/app/features/watermark/components/Dropzone.tsx b/apps/qrcampaign/src/app/features/watermark/components/Dropzone.tsx new file mode 100644 index 0000000..5f346c4 --- /dev/null +++ b/apps/qrcampaign/src/app/features/watermark/components/Dropzone.tsx @@ -0,0 +1,99 @@ +import React, { useCallback, useState } from 'react'; +import { toast } from 'sonner'; + +interface DropzoneProps { + onImageDropped: (file: File) => void; +} + +export const Dropzone: React.FC = ({ onImageDropped }) => { + const [isDragging, setIsDragging] = useState(false); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + + const file = files[0]; + if (!file.type.startsWith('image/')) { + toast.error('Please upload an image file.'); + return; + } + + onImageDropped(file); + }, + [onImageDropped] + ); + + const handleFileInput = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + const file = files[0]; + if (!file.type.startsWith('image/')) { + toast.error('Please upload an image file.'); + return; + } + onImageDropped(file); + } + }, + [onImageDropped] + ); + + return ( +
document.getElementById('file-upload')?.click()} + > + +
+
+ {/* Simple upload icon */} + + + +
+

+ Drop your image here, or click to upload +

+

Supports JPG and PNG

+
+
+ ); +}; diff --git a/apps/qrcampaign/src/app/features/watermark/components/WatermarkEditor.tsx b/apps/qrcampaign/src/app/features/watermark/components/WatermarkEditor.tsx new file mode 100644 index 0000000..1854cc9 --- /dev/null +++ b/apps/qrcampaign/src/app/features/watermark/components/WatermarkEditor.tsx @@ -0,0 +1,188 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import html2canvas from 'html2canvas'; +import { toast } from 'sonner'; + +interface WatermarkEditorProps { + imageFile: File; + qrCodeUrl: string; + onReset: () => void; +} + +export const WatermarkEditor: React.FC = ({ + imageFile, + qrCodeUrl, + onReset, +}) => { + const [imageUrl, setImageUrl] = useState(null); + const containerRef = useRef(null); + const qrRef = useRef(null); + + // State for QR code + const [position, setPosition] = useState({ x: 20, y: 20 }); + const [size, setSize] = useState(100); + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + const [startResizePos, setStartResizePos] = useState({ x: 0, y: 0 }); + const [startResizeSize, setStartResizeSize] = useState(100); + + // Load image + useEffect(() => { + const url = URL.createObjectURL(imageFile); + setImageUrl(url); + return () => URL.revokeObjectURL(url); + }, [imageFile]); + + // Drag handlers + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + setDragOffset({ + x: e.clientX - position.x, + y: e.clientY - position.y, + }); + }; + + const handleResizeMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + setStartResizePos({ x: e.clientX, y: e.clientY }); + setStartResizeSize(size); + }; + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (isDragging) { + const newX = e.clientX - dragOffset.x; + const newY = e.clientY - dragOffset.y; + + // Boundaries check (optional, but good UX) + if (containerRef.current) { + // const container = containerRef.current.getBoundingClientRect(); + // Simple clamp? Or allow partial off-screen? + // Let's allow it to move freely within container + } + + setPosition({ x: newX, y: newY }); + } + + if (isResizing) { + const deltaX = e.clientX - startResizePos.x; + const newSize = Math.max(50, startResizeSize + deltaX); // Min size 50px + setSize(newSize); + } + }, + [isDragging, isResizing, dragOffset, startResizePos, startResizeSize] + ); + + const handleMouseUp = useCallback(() => { + setIsDragging(false); + setIsResizing(false); + }, []); + + useEffect(() => { + if (isDragging || isResizing) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + } else { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + } + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, [isDragging, isResizing, handleMouseMove, handleMouseUp]); + + const handleDownload = async () => { + if (!containerRef.current) return; + + try { + const canvas = await html2canvas(containerRef.current, { + useCORS: true, // Important for QR if from external URL + backgroundColor: null, + }); + + const link = document.createElement('a'); + link.download = `qr-campaign-${Date.now()}.png`; + link.href = canvas.toDataURL('image/png'); + link.click(); + toast.success('Image downloaded successfully!'); + } catch (error) { + console.error('Download failed:', error); + toast.error('Failed to download image.'); + } + }; + + if (!imageUrl) return
Loading image...
; + + return ( +
+
+ + +
+ +
+
+ Uploaded + +
+ QR Code + + {/* Outline on hover/interaction */} +
+ + {/* Resize handle */} +
+
+
+
+ +

+ Drag to move the QR code. Drag the blue dot to resize. +

+
+ ); +}; diff --git a/apps/qrcampaign/src/app/layout.tsx b/apps/qrcampaign/src/app/layout.tsx new file mode 100644 index 0000000..c014d0b --- /dev/null +++ b/apps/qrcampaign/src/app/layout.tsx @@ -0,0 +1,83 @@ +import { + Outlet, + ScrollRestoration, + useLocation, + useNavigate, +} from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { useAuthStore } from './features/auth/store/auth.store'; +import { Sidebar } from '../components/Sidebar'; +import { MenuOutlined } from '@ant-design/icons'; + +// Helper to determine route types +const isPublicRoute = (pathname: string) => { + return pathname.startsWith('/auth') || pathname === '/auth/callback'; +}; + +export default function RootLayout() { + const location = useLocation(); + const navigate = useNavigate(); + const { isAuthenticated } = useAuthStore(); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + + useEffect(() => { + // If we're on a public route, no auth check needed + if (isPublicRoute(location.pathname)) { + return; + } + + // AUTH CHECK + if (!isAuthenticated) { + // No session -> Redirect to login + navigate('/auth/login', { replace: true }); + return; + } + }, [location.pathname, navigate, isAuthenticated]); + + // RENDER LOGIC + + // 1. Public Pages (Full Layout Control) + if (isPublicRoute(location.pathname)) { + return ( + <> + + + + ); + } + + // 2. Protected Pages + if (!isAuthenticated) { + return null; + } + + return ( +
+ setMobileSidebarOpen(false)} + /> + +
+ {/* Mobile Header */} +
+
+ +

QR Campaign

+
+
+ +
+ +
+
+ + +
+ ); +} diff --git a/apps/qrcampaign/src/app/nx-welcome.tsx b/apps/qrcampaign/src/app/nx-welcome.tsx deleted file mode 100644 index f3c5c7a..0000000 --- a/apps/qrcampaign/src/app/nx-welcome.tsx +++ /dev/null @@ -1,856 +0,0 @@ -/* - * * * * * * * * * * * * * * * * * * * * * * * * * * * * - This is a starter component and can be deleted. - * * * * * * * * * * * * * * * * * * * * * * * * * * * * - Delete this file and get started with your project! - * * * * * * * * * * * * * * * * * * * * * * * * * * * * - */ -export function NxWelcome({ title }: { title: string }) { - return ( - <> -