diff --git a/README.md b/README.md index effee35..477120a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ This repository is a **monorepo** for all frontend services of IMPHNEN. The mono 2. **Backoffice** - Application for Internal Management Website. 3. **Dimentorin** - Application for Mentoring Service. 4. **Landing Page** - Application for Landing Page. +5. **QR Campaign** - Application for QR Campaign Management. ## How to install @@ -54,6 +55,10 @@ Use the following commands to run in development mode: ```sh npm run landing:dev ``` +- **QR Campaign**: + ```sh + npm run qrcampaign:dev + ``` ### Build @@ -75,6 +80,10 @@ Use the following commands to build the applications: ```sh npm run landing:build ``` +- **QR Campaign**: + ```sh + npm run qrcampaign:build + ``` ### Production @@ -96,6 +105,10 @@ Use the following commands to run the applications in production mode: ```sh npm run landing:prod ``` +- **QR Campaign**: + ```sh + npm run qrcampaign:prod + ``` ### Storybook diff --git a/apps/qrcampaign/Dockerfile b/apps/qrcampaign/Dockerfile new file mode 100644 index 0000000..80582ff --- /dev/null +++ b/apps/qrcampaign/Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --prefer-offline --no-audit --no-fund + +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NX_DAEMON=false +RUN npm run qrcampaign:build + +FROM nginx:alpine AS runner +RUN rm -rf /usr/share/nginx/html/* +COPY --from=builder /app/dist/apps/qrcampaign /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/qrcampaign/eslint.config.mjs b/apps/qrcampaign/eslint.config.mjs new file mode 100644 index 0000000..e8e4590 --- /dev/null +++ b/apps/qrcampaign/eslint.config.mjs @@ -0,0 +1,12 @@ +import nx from '@nx/eslint-plugin'; +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + ...nx.configs['flat/react'], + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + // Override or add rules here + rules: {}, + }, +]; diff --git a/apps/qrcampaign/index.html b/apps/qrcampaign/index.html new file mode 100644 index 0000000..78379f6 --- /dev/null +++ b/apps/qrcampaign/index.html @@ -0,0 +1,16 @@ + + + + + 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/project.json b/apps/qrcampaign/project.json new file mode 100644 index 0000000..d02ef81 --- /dev/null +++ b/apps/qrcampaign/project.json @@ -0,0 +1,9 @@ +{ + "name": "qrcampaign", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/qrcampaign/src", + "projectType": "application", + "tags": [], + "// targets": "to see all targets run: nx show project qrcampaign --web", + "targets": {} +} diff --git a/apps/qrcampaign/public/images/imphnen-logo.svg b/apps/qrcampaign/public/images/imphnen-logo.svg new file mode 100644 index 0000000..ca0f36e --- /dev/null +++ b/apps/qrcampaign/public/images/imphnen-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + 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/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/page.tsx b/apps/qrcampaign/src/app/page.tsx new file mode 100644 index 0000000..6616825 --- /dev/null +++ b/apps/qrcampaign/src/app/page.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { Dropzone } from './features/watermark/components/Dropzone'; +import { Button } from '@imphnen-frontend-service/ui/atoms'; +import { toast } from 'sonner'; +import { api } from './features/auth/api/auth.service'; + +export default function HomePage() { + const [imageFile, setImageFile] = useState(null); + const [generatedImage, setGeneratedImage] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const handleImageDropped = (file: File) => { + setImageFile(file); + setGeneratedImage(null); // Reset previous result + toast.success('Image selected ready for generation!'); + }; + + const handleReset = () => { + setImageFile(null); + setGeneratedImage(null); + }; + + const handleGenerate = async () => { + if (!imageFile) return; + + setIsLoading(true); + const formData = new FormData(); + formData.append('image', imageFile); + + try { + const response = await api.post('/campaigns/process-image', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + responseType: 'blob', + }); + + const imageUrl = URL.createObjectURL(response.data); + setGeneratedImage(imageUrl); + toast.success('QR Code generated successfully!'); + } catch (error: any) { + console.error(error); + const message = + error.response?.data?.message || 'Failed to generate QR code.'; + toast.error(message); + } finally { + setIsLoading(false); + } + }; + + const handleDownload = () => { + if (!generatedImage) return; + const link = document.createElement('a'); + link.href = generatedImage; + link.download = `qr-campaign-${Date.now()}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + return ( +
+
+

+ QR Code Generator +

+

+ Upload your image to add the campaign QR code watermark. +

+
+ +
+ {!imageFile ? ( + + ) : ( +
+
+ {isLoading ? ( +
+
+

+ Processing image... +

+
+ ) : generatedImage ? ( + Generated with QR + ) : ( +
+ Original +
+ + Original Image + +
+
+ )} +
+ +
+ {!generatedImage && !isLoading && ( + <> + + + + )} + + {generatedImage && ( + <> + + + + )} +
+
+ )} +
+
+ ); +} diff --git a/apps/qrcampaign/src/assets/.gitkeep b/apps/qrcampaign/src/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/qrcampaign/src/components/Sidebar.tsx b/apps/qrcampaign/src/components/Sidebar.tsx new file mode 100644 index 0000000..20e1d38 --- /dev/null +++ b/apps/qrcampaign/src/components/Sidebar.tsx @@ -0,0 +1,229 @@ +import { + AppstoreOutlined, + UsergroupAddOutlined, + QrcodeOutlined, + LogoutOutlined, + DownOutlined, + RightOutlined, +} from '@ant-design/icons'; +import { Button } from '@imphnen-frontend-service/ui/atoms'; +import { FC, ReactElement, useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { cn, For } from '@imphnen-frontend-service/utils'; +import { useAuthStore } from '../app/features/auth/store/auth.store'; + +type MenuItem = { + label: string; + href?: string; + icon?: ReactElement; + children?: Array<{ label: string; href: string; icon?: ReactElement }>; + roles?: string[]; // Roles that can see this menu +}; + +const MENUS: MenuItem[] = [ + { + label: 'QR Generator', + href: '/', + icon: , + roles: ['User', 'Admin', 'Super Admin'], + }, + { + label: 'Campaign Management', + href: '/admin/campaigns', + icon: , + roles: ['Admin', 'Super Admin'], + }, + { + label: 'User Management', + href: '/admin/users', + icon: , + roles: ['Admin', 'Super Admin'], + }, +]; + +interface SidebarProps { + isOpen?: boolean; + onClose?: () => void; +} + +export const Sidebar: FC = ({ + isOpen = false, + onClose, +}): ReactElement => { + const { user, logout } = useAuthStore(); + const location = useLocation(); + const [openGroups, setOpenGroups] = useState>({}); + + const userRole = user?.role?.name || 'User'; + + const isActive = (path: string) => { + if (path === '/' && location.pathname !== '/') return false; + return location.pathname.startsWith(path); + }; + + const toggleGroup = (groupLabel: string) => { + setOpenGroups((prev) => ({ ...prev, [groupLabel]: !prev[groupLabel] })); + }; + + // Filter menus based on role + const filteredMenus = MENUS.filter((menu) => { + if (!menu.roles) return true; + return menu.roles.includes(userRole); + }); + + const sidebarContent = ( +
+
+
+ IMPHNEN Logo + {onClose && ( + + )} +
+
+ + {/* Navigation - Scrollable area */} + + + {/* Footer - Fixed at bottom */} +
+
+
+
+ Signed in as +
+
+
+ {user?.fullname?.charAt(0) || 'U'} +
+
+ + {user?.fullname} + + + {user?.role?.name} + +
+
+
+ +
+
+ ); + + return ( + <> +
+ {sidebarContent} +
+ + {isOpen && ( +
+
+
+ {sidebarContent} +
+
+ )} + + ); +}; diff --git a/apps/qrcampaign/src/index.css b/apps/qrcampaign/src/index.css new file mode 100644 index 0000000..16b5a76 --- /dev/null +++ b/apps/qrcampaign/src/index.css @@ -0,0 +1,136 @@ +@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap'); +@import 'tailwindcss'; +@source "../../../libs/ui/**/*.{ts,tsx}"; + +@theme { + --color-primary-50: #f0f8ff; + --color-primary-100: #e1f0fd; + --color-primary-200: #bce1fb; + --color-primary-300: #81cbf8; + --color-primary-400: #3eb0f2; + --color-primary-500: #23a1eb; + --color-primary-600: #0877c1; + --color-primary-700: #085f9c; + --color-primary-800: #0b5181; + --color-primary-900: #0f446b; + --color-primary-950: #0a2b47; + + --color-neutral-50: #f6f6f6; + --color-neutral-100: #e7e7e7; + --color-neutral-200: #d1d1d1; + --color-neutral-300: #b0b0b0; + --color-neutral-400: #888888; + --color-neutral-500: #6e6a86; + --color-neutral-600: #56526e; + --color-neutral-700: #44415a; + --color-neutral-800: #393552; + --color-neutral-900: #2a273f; + --color-neutral-950: #232136; + + --color-success-100: #e0fbd8; + --color-success-200: #bcf8b0; + --color-success-300: #8eea85; + --color-success-400: #63d564; + --color-success-500: #35ba43; + --color-success-600: #269f3e; + --color-success-700: #1a8439; + --color-success-800: #106b32; + --color-success-900: #0b592f; + + --color-info-100: #ccfcfe; + --color-info-200: #9bf3fd; + --color-info-300: #67e3fb; + --color-info-400: #42cdf8; + --color-info-500: #04acf3; + --color-info-600: #0185d0; + --color-info-700: #0264af; + --color-info-800: #01478d; + --color-info-900: #003375; + + --color-warning-100: #fffcd3; + --color-warning-200: #fffaa9; + --color-warning-300: #fff67d; + --color-warning-400: #fff25d; + --color-warning-500: #ffed27; + --color-warning-600: #dbc91d; + --color-warning-700: #b7a714; + --color-warning-800: #93850b; + --color-warning-900: #7a6d07; + + --color-danger-100: #ffe8da; + --color-danger-200: #ffcbb3; + --color-danger-300: #ffaa8d; + --color-danger-400: #ff8870; + --color-danger-500: #ff5242; + --color-danger-600: #da3030; + --color-danger-700: #b7212d; + --color-danger-800: #93152a; + --color-danger-900: #7a0c27; + + --radius-none: 0px; + --radius-sm: 2px; + --radius-md: 4px; + --radius-lg: 8px; + --radius-xl: 12px; + --radius-2xl: 16px; + --radius-3xl: 24px; + --radius-full: 50%; + + --font-bai-jamjuree: 'Bai Jamjuree', sans-serif; + + --text-h1: 3.833rem; + /* ~46px */ + --text-h2: 3.083rem; + /* ~37px */ + --text-h3: 2.417rem; + /* ~29px */ + --text-p1: 1.917rem; + /* ~23px */ + --text-p2: 1.583rem; + /* ~19px */ + --text-p3: 1.25rem; + /* 15px */ + --text-label1: 1rem; + /* 12px */ + --text-label2: 0.833rem; + /* ~10px */ + --text-label3: 0.677rem; + /* ~8px */ +} + +@layer base { + + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-neutral-200, currentColor); + } + + /* Custom scrollbar */ + ::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + ::-webkit-scrollbar-track { + background: var(--color-neutral-50); + } + + ::-webkit-scrollbar-thumb { + background: #cccccc; + border-radius: 3px; + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--color-neutral-300); + } + + html { + font-family: 'Bai Jamjuree', sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 1.2; + } +} diff --git a/apps/qrcampaign/src/main.tsx b/apps/qrcampaign/src/main.tsx new file mode 100644 index 0000000..0c9d268 --- /dev/null +++ b/apps/qrcampaign/src/main.tsx @@ -0,0 +1,42 @@ +import { createRoot } from 'react-dom/client'; +import { StrictMode } from 'react'; +import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router'; +import { + add404PageToRoutesChildren, + addErrorElementToRoutes, + convertPagesToRoute, + ModalLoginProvider, + QueryProvider, +} from '@imphnen-frontend-service/utils'; +import { Toaster } from 'sonner'; +import './index.css'; + +const files = import.meta.glob('./app/**/*(page|layout).tsx'); +const errorFiles = import.meta.glob('./app/**/*error.tsx'); +const notFoundFiles = import.meta.glob('./app/**/*404.tsx'); +const loadingFiles = import.meta.glob('./app/**/*loading.tsx'); + +const routes = convertPagesToRoute(files, loadingFiles) as RouteObject; +addErrorElementToRoutes(errorFiles, routes); +add404PageToRoutesChildren(notFoundFiles, routes); + +const router = createBrowserRouter([ + { + ...routes, + }, +]); + +const rootElement = document.getElementById('root'); + +if (!rootElement) throw new Error('Failed to find the root element'); + +createRoot(rootElement).render( + + + + + + + + +); diff --git a/apps/qrcampaign/src/styles.css b/apps/qrcampaign/src/styles.css new file mode 100644 index 0000000..90d4ee0 --- /dev/null +++ b/apps/qrcampaign/src/styles.css @@ -0,0 +1 @@ +/* You can add global styles to this file, and also import other style files */ diff --git a/apps/qrcampaign/tsconfig.app.json b/apps/qrcampaign/tsconfig.app.json new file mode 100644 index 0000000..6d35def --- /dev/null +++ b/apps/qrcampaign/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts", + "vite/client" + ] + }, + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.js", + "src/**/*.test.js", + "src/**/*.spec.jsx", + "src/**/*.test.jsx", + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/apps/qrcampaign/tsconfig.json b/apps/qrcampaign/tsconfig.json new file mode 100644 index 0000000..fdfab6c --- /dev/null +++ b/apps/qrcampaign/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "types": ["vite/client", "vitest"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/apps/qrcampaign/tsconfig.spec.json b/apps/qrcampaign/tsconfig.spec.json new file mode 100644 index 0000000..9d3bb72 --- /dev/null +++ b/apps/qrcampaign/tsconfig.spec.json @@ -0,0 +1,30 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "include": [ + "vite.config.ts", + "vite.config.mts", + "vitest.config.ts", + "vitest.config.mts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/apps/qrcampaign/vite.config.mts b/apps/qrcampaign/vite.config.mts new file mode 100644 index 0000000..78962ee --- /dev/null +++ b/apps/qrcampaign/vite.config.mts @@ -0,0 +1,43 @@ +/// +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin'; + +export default defineConfig(() => ({ + root: import.meta.dirname, + cacheDir: '../../node_modules/.vite/apps/qrcampaign', + server: { + port: 3008, + host: 'localhost', + }, + preview: { + port: 3008, + host: 'localhost', + }, + plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])], + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + build: { + outDir: '../../dist/apps/qrcampaign', + emptyOutDir: true, + reportCompressedSize: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + }, + test: { + name: 'qrcampaign', + watch: false, + globals: true, + environment: 'jsdom', + include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + reporters: ['default'], + coverage: { + reportsDirectory: '../../coverage/apps/qrcampaign', + provider: 'v8' as const, + }, + }, +})); diff --git a/docker-compose-qrcampaign.yml b/docker-compose-qrcampaign.yml new file mode 100644 index 0000000..f4e9c8f --- /dev/null +++ b/docker-compose-qrcampaign.yml @@ -0,0 +1,9 @@ +services: + qrcampaign: + build: + context: . + dockerfile: docker/qrcampaign.Dockerfile + ports: + - '127.0.0.1:3006:80' + restart: unless-stopped + container_name: qrcampaign diff --git a/docker/qrcampaign.Dockerfile b/docker/qrcampaign.Dockerfile new file mode 100644 index 0000000..80582ff --- /dev/null +++ b/docker/qrcampaign.Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --prefer-offline --no-audit --no-fund + +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NX_DAEMON=false +RUN npm run qrcampaign:build + +FROM nginx:alpine AS runner +RUN rm -rf /usr/share/nginx/html/* +COPY --from=builder /app/dist/apps/qrcampaign /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/flake.nix b/flake.nix index 4440357..ae5c466 100644 --- a/flake.nix +++ b/flake.nix @@ -33,6 +33,7 @@ dimentorin = mkViteApp { name = "dimentorin"; buildScript = "dimentorin:build"; }; hackathon = mkViteApp { name = "hackathon"; buildScript = "hackathon:build"; }; infra = mkViteApp { name = "infra"; buildScript = "infra:build"; }; + qrcampaign = mkViteApp { name = "qrcampaign"; buildScript = "qrcampaign:build"; }; # Next.js app landing = landingApp; @@ -63,7 +64,7 @@ echo " nx dev - Start development server" echo " nx build - Build an app" echo "" - echo "Apps: landing, backoffice, gacha, dimentorin, hackathon" + echo "Apps: landing, backoffice, gacha, dimentorin, hackathon, qrcampaign" echo "" echo "Build packages with:" echo " nix build .#landing" @@ -71,6 +72,7 @@ echo " nix build .#gacha" echo " nix build .#dimentorin" echo " nix build .#hackathon" + echo " nix build .#qrcampaign" ''; }; } @@ -84,6 +86,7 @@ dimentorin = import ./nix/modules/static-app.nix { inherit self; } { appName = "dimentorin"; }; hackathon = import ./nix/modules/static-app.nix { inherit self; } { appName = "hackathon"; }; infra = import ./nix/modules/static-app.nix { inherit self; } { appName = "infra"; }; + qrcampaign = import ./nix/modules/static-app.nix { inherit self; } { appName = "qrcampaign"; }; # All-in-one module that enables all apps all = { config, lib, pkgs, ... }: { @@ -94,6 +97,7 @@ self.nixosModules.dimentorin self.nixosModules.hackathon self.nixosModules.infra + self.nixosModules.qrcampaign ]; }; }; @@ -107,6 +111,7 @@ dimentorin = self.packages.${final.system}.dimentorin; hackathon = self.packages.${final.system}.hackathon; infra = self.packages.${final.system}.infra; + qrcampaign = self.packages.${final.system}.qrcampaign; # Function to build hackathon with custom environment variables (e.g., Supabase) mkHackathonWithEnv = envVars: import ./nix/mkViteApp.nix { pkgs = final; diff --git a/nx.json b/nx.json index 0e36afa..7496f7c 100644 --- a/nx.json +++ b/nx.json @@ -53,6 +53,13 @@ "options": { "targetName": "e2e" } + }, + { + "plugin": "@nx/vitest", + "options": { + "testTargetName": "vitest:test", + "ciTargetName": "test-ci" + } } ], "generators": { diff --git a/package.json b/package.json index aa9f221..4649677 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,10 @@ "landing:build": "nx build landing", "landing:prod": "cd ./dist/apps/landing && npx next start", "landing:openapi": "npx openapi-typescript https://api.imphnen.dev/openapi.json -o ./apps/landing/src/openapi-types.ts", + "qrcampaign:dev": "nx dev qrcampaign", + "qrcampaign:build": "nx build qrcampaign", + "qrcampaign:test": "nx test qrcampaign", + "qrcampaign:prod": "serve ./dist/apps/qrcampaign", "ui:test": "nx test ui", "ui:build": "nx build ui" }, @@ -94,7 +98,7 @@ "@tailwindcss/postcss": "^4.0.13", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.1.0", + "@testing-library/react": "16.1.0", "@testing-library/user-event": "^14.6.1", "@types/js-cookie": "^3.0.6", "@types/node": "^22.12.0",