diff --git a/package.json b/package.json index cc03954..f36e818 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "lucide-react": "^1.14.0", "nanoid": "^5.1.11", "next-themes": "^0.4.6", + "qrcode.react": "^4.2.0", "radix-ui": "^1.4.3", "react": "^19.2.5", "react-day-picker": "^9.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4218d5a..ff838c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.5) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -2829,6 +2832,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + qs@6.15.1: resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} engines: {node: '>=0.6'} @@ -5991,6 +5999,10 @@ snapshots: punycode@2.3.1: {} + qrcode.react@4.2.0(react@19.2.5): + dependencies: + react: 19.2.5 + qs@6.15.1: dependencies: side-channel: 1.1.0 diff --git a/src/components/generic/OverflowBadges.jsx b/src/components/generic/OverflowBadges.jsx new file mode 100644 index 0000000..09ac184 --- /dev/null +++ b/src/components/generic/OverflowBadges.jsx @@ -0,0 +1,124 @@ +/*********************************************************************************************************************************************************************** + * File Name: OverflowBadges.jsx + * Type of Program: Generic Component + * Description: Renders up to `max` badges inline. Remaining items collapse into a + * "+N" overflow button that opens a dialog listing all items. + * + * HOW TO USE: + * + * + * // Primitive arrays (no keys needed): + * + ***********************************************************************************************************************************************************************/ +import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +/** + * @param {object} props + * @param {Array} props.items Array of objects or primitives to render. + * @param {string} [props.labelKey] Key on each item to use as the badge label. Omit for primitive arrays. + * @param {string} [props.dialogTitleKey] Key on each item to show as the row title in the dialog. Falls back to labelKey. + * @param {string} [props.keyKey] Key on each item to use as the React key. Falls back to index. + * @param {string} [props.dialogTitle] Heading shown in the overflow dialog. Default: "All items". + * @param {number} [props.max] Max badges shown inline before collapsing. Default: 2. + * @param {string} [props.badgeVariant] shadcn Badge variant. Default: "outline". + * @param {string} [props.badgeClassName] Extra className on each Badge. + * @param {string} [props.emptyText] Text shown when items is empty. Default: "—". + */ +export function OverflowBadges({ + items = [], + labelKey, + dialogTitleKey, + keyKey, + dialogTitle = "All items", + max = 1, + badgeVariant = "outline", + badgeClassName = "text-xs", + emptyText = "—", +}) { + const [open, setOpen] = useState(false); + + if (!items.length) + return {emptyText}; + + const getLabel = (item) => + labelKey ? item[labelKey] : String(item); + + const getTitle = (item) => + dialogTitleKey ? item[dialogTitleKey] : labelKey ? item[labelKey] : String(item); + + const getKey = (item, i) => + keyKey ? item[keyKey] : i; + + const visible = items.slice(0, max); + const overflow = items.slice(max); + + return ( + <> +
+ {visible.map((item, i) => ( + + {getLabel(item)} + + ))} + + {overflow.length > 0 && ( + + )} +
+ + + + + {dialogTitle} + +
+ {items.map((item, i) => { + const label = getLabel(item); + const title = getTitle(item); + const isSame = label === title; + + return ( +
+ {title} + {!isSame && ( + + {label} + + )} +
+ ); + })} +
+
+
+ + ); +} \ No newline at end of file diff --git a/src/contexts/AdminUserGroupContext.jsx b/src/contexts/AdminUserGroupContext.jsx index 95dc4e0..8919f9b 100644 --- a/src/contexts/AdminUserGroupContext.jsx +++ b/src/contexts/AdminUserGroupContext.jsx @@ -117,9 +117,9 @@ export function UserGroupProvider({ children }) { // ─── POST /api/admin/groups ──────────────────────────────────────────────── const createGroup = useCallback( - ({ name, description }) => + ({ name, description, group_code }) => request(async () => { - const res = await api.post(`${BASE}/groups`, { name, description }); + const res = await api.post(`${BASE}/groups`, { name, description, group_code }); setGroups((prev) => [res.data?.data, ...prev]); toast.success("Group created successfully."); return res.data; @@ -129,9 +129,9 @@ export function UserGroupProvider({ children }) { // ─── PUT /api/admin/groups/:gid ─────────────────────────────────────────── const updateGroup = useCallback( - (gid, { name, description }) => + (gid, { name, description, group_code }) => request(async () => { - const res = await api.put(`${BASE}/groups/${gid}`, { name, description }); + const res = await api.put(`${BASE}/groups/${gid}`, { name, description, group_code }); setGroups((prev) => prev.map((g) => (g.group_id === gid ? { ...g, ...res.data?.data } : g)) ); diff --git a/src/contexts/AuthContext.jsx b/src/contexts/AuthContext.jsx index 6073426..6ab1afc 100644 --- a/src/contexts/AuthContext.jsx +++ b/src/contexts/AuthContext.jsx @@ -7,7 +7,7 @@ const AuthContext = createContext(null) export const decodeToken = (token) => JSON.parse(atob(token.split('.')[1])) export function AuthProvider({ children }) { - const [accessToken, _setAccessToken] = useState(null) // ← renamed to _setAccessToken + const [accessToken, _setAccessToken] = useState(null) const [user, setUser] = useState(null) const [loading, setLoading] = useState(true) const [sessionRestored, setSessionRestored] = useState(false) @@ -16,12 +16,12 @@ export function AuthProvider({ children }) { const isRestoring = useRef(false) const accessTokenRef = useRef(null) - // ← Single setter that updates both ref and state const setAccessToken = useCallback((token) => { accessTokenRef.current = token _setAccessToken(token) }, []) + // ── Login ────────────────────────────────────────────────────────────────── const login = useCallback(async ({ email, password }) => { setAuthError(null) try { @@ -36,6 +36,46 @@ export function AuthProvider({ children }) { } }, []) + // ── Register ─────────────────────────────────────────────────────────────── + const register = useCallback(async (payload) => { + setAuthError(null) + try { + await api.post('/auth/register', payload) + return { success: true } + } catch (err) { + const message = err.response?.data?.message || 'Registration failed. Please try again.' + setAuthError(message) + return { success: false, message } + } + }, []) + + // ── Verify OTP (auto-login) ──────────────────────────────────────────────── + const verifyOTP = useCallback(async ({ email, otp }) => { + setAuthError(null) + try { + const { data } = await api.post('/auth/verify-otp', { email, otp }) + setAccessToken(data.data.accessToken) + setUser(data.data.user) + return { success: true, user: data.data.user } + } catch (err) { + const message = err.response?.data?.message || 'OTP verification failed.' + setAuthError(message) + return { success: false, message } + } + }, []) + + // ── Resend OTP ───────────────────────────────────────────────────────────── + const resendOTP = useCallback(async ({ email }) => { + try { + await api.post('/auth/resend-otp', { email }) + return { success: true } + } catch (err) { + const message = err.response?.data?.message || 'Could not resend OTP.' + return { success: false, message } + } + }, []) + + // ── Logout ───────────────────────────────────────────────────────────────── const logout = useCallback(async () => { try { await api.post('/auth/logout') @@ -47,12 +87,13 @@ export function AuthProvider({ children }) { } }, []) + // ── Restore session ──────────────────────────────────────────────────────── const restoreSession = useCallback(async () => { if (isRestoring.current) return isRestoring.current = true try { - if (accessTokenRef.current) return // already have token, skip + if (accessTokenRef.current) return const { data } = await api.post('/auth/refresh') setAccessToken(data.data.accessToken) setUser(data.data.user) @@ -60,18 +101,21 @@ export function AuthProvider({ children }) { setAccessToken(null) setUser(null) } finally { - setLoading(false) // ← set once, never back to true + setLoading(false) } }, []) return ( @@ -90,13 +112,24 @@ export function AddGroupDialog({ open, onOpenChange, onSubmit, loading }) { )} +
+ + + {errors.group_code && ( +

{errors.group_code.message}

+ )} +
+ - + +
+
+ or share the link +
+
+ + {/* Invite link */} +
+
+

+ {inviteUrl} +

+
+ +
+ +
+ + + ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── export default function ViewGroup() { const { groupId } = useParams(); const tableRefsRef = useRef({ - getFilters: () => [], - getSort: () => [], - resetSelection: () => { }, - setFilters: () => { }, + getFilters: () => [], + getSort: () => [], + resetSelection: () => {}, + setFilters: () => {}, }); - const [addMemberOpen, setAddMemberOpen] = useState(false); - const [archiveTarget, setArchiveTarget] = useState(null); - const [archiveIds, setArchiveIds] = useState(null); - const [memberAttrs, setMemberAttrs] = useState([]); + const [addMemberOpen, setAddMemberOpen] = useState(false); + const [inviteOpen, setInviteOpen] = useState(false); + const [archiveTarget, setArchiveTarget] = useState(null); + const [archiveIds, setArchiveIds] = useState(null); + const [memberAttrs, setMemberAttrs] = useState([]); const { group, @@ -57,14 +158,14 @@ export default function ViewGroup() { }, []); const handleRefsReady = (refs) => { - tableRefsRef.current = refs; // ← just store refs directly, nothing else needed + tableRefsRef.current = refs; }; const exportConfig = { - allData: members, + allData: members, attributes: memberAttrs, - filename: `${getTimestamp()}_Group_${groupId}_Members`, - sheetName: "Members", + filename: `${getTimestamp()}_Group_${groupId}_Members`, + sheetName: "Members", }; const rowActions = buildRowActions({ @@ -77,13 +178,13 @@ export default function ViewGroup() { pagination, exportConfig, onAddMember: () => setAddMemberOpen(true), - getFilters: () => tableRefsRef.current.getFilters(), - getSort: () => tableRefsRef.current.getSort(), + getFilters: () => tableRefsRef.current.getFilters(), + getSort: () => tableRefsRef.current.getSort(), }); const selectionActions = buildSelectionActions({ exportConfig, - onRemoveMember: (row) => setArchiveTarget(row), + onRemoveMember: (row) => setArchiveTarget(row), onRemoveMembers: (ids) => setArchiveIds(ids), }); @@ -100,26 +201,26 @@ export default function ViewGroup() { }; const breadcrumbItems = [ - { label: "Home", icon: , to: "/admin" }, + { label: "Home", icon: , to: "/admin" }, { label: "User Groups", to: "/admin/groups" }, { label: group?.name ?? "View Group" }, ]; const formattedCreated = group?.createdAt ? new Date(group.createdAt).toLocaleDateString("en-PH", { - year: "numeric", month: "long", day: "numeric", - }) + year: "numeric", month: "long", day: "numeric", + }) : "—"; const formattedUpdated = group?.updatedAt ? new Date(group.updatedAt).toLocaleDateString("en-PH", { - year: "numeric", month: "long", day: "numeric", - }) + year: "numeric", month: "long", day: "numeric", + }) : "—"; const handleFetch = useCallback( (params) => fetchGroup(groupId, params), - [groupId] // fetchGroup should be useCallback'd in context + [groupId] ); return ( @@ -132,32 +233,48 @@ export default function ViewGroup() {
- {/* ── Group detail card ─────────────────────────────────────────── */} + {/* ── Group detail card ──────────────────────────────────────────── */}
-
-
-

- {group?.name ?? "—"} -

- +
+
+

+ {group?.name ?? "—"} +

+ - {group?.is_active ? "Active" : "Inactive"} - + > + {group?.is_active ? "Active" : "Inactive"} + +
+

+ {group?.description ?? "—"} +

-

- {group?.description ?? "—"} -

+ + {/* ── Generate invite link button ── */} + {group?.group_code && ( + + )}
{[ - { label: "Group ID", value: group?.group_id ? `#${group.group_id}` : "—" }, - { label: "Members", value: pagination?.totalRecords ?? 0, icon: }, - { label: "Created", value: formattedCreated }, + { label: "Group Code", value: group?.group_code ?? "—" }, + { label: "Members", value: pagination?.totalRecords ?? 0, icon: }, + { label: "Created", value: formattedCreated }, { label: "Last Updated", value: formattedUpdated }, ].map(({ label, value, icon }) => (
@@ -172,7 +289,7 @@ export default function ViewGroup() {
- {/* ── Members table ─────────────────────────────────────────────── */} + {/* ── Members table ──────────────────────────────────────────────── */}
- {/* ── Add member — generic sheet ───────────────────────────────────── */} + {/* ── Invite link dialog ────────────────────────────────────────────── */} + + + {/* ── Add member ───────────────────────────────────────────────────── */} - {/* ── Single remove ────────────────────────────────────────────────── */} + {/* ── Single remove ─────────────────────────────────────────────────── */} !v && setArchiveTarget(null)} entity={archiveTarget} entityLabel="Member" getName={(m) => m?.personal_info?.name?.full_name ?? m?.email} - onArchive={(m) => removeUsersFromGroup(groupId, [m?.user_id])} // ← stays the same + onArchive={(m) => removeUsersFromGroup(groupId, [m?.user_id])} loading={loading} onSuccess={handleRemoveSuccess} /> - {/* ── Bulk remove ──────────────────────────────────────────────────── */} + {/* ── Bulk remove ───────────────────────────────────────────────────── */} !v && setArchiveIds(null)} ids={archiveIds ?? []} entityLabel="Member" - onArchive={({ ids }) => removeUsersFromGroup(groupId, ids)} // ← destructure { ids } + onArchive={({ ids }) => removeUsersFromGroup(groupId, ids)} loading={loading} onSuccess={handleRemoveSuccess} /> diff --git a/src/modules/auth/components/LoginForm.jsx b/src/modules/auth/components/LoginForm.jsx index a4c5f8d..2275448 100644 --- a/src/modules/auth/components/LoginForm.jsx +++ b/src/modules/auth/components/LoginForm.jsx @@ -1,18 +1,20 @@ /*********************************************************************************************************************************************************************** -* File Name: login-form.jsx -* Type of Program: Frontend Layout -* Description: Frontend layout Login Page. -* Module: User Credentials -* Author: lash0000 -* Date Created: Oct. 10, 2025 -*********************************************************************************************************************************************************************** -* Change History: -* DATE AUTHOR LOG NUMBER DESCRIPTION -* Oct. 10, 2025 lash0000 001 Initial creation - STAR Phase 1 Project -***********************************************************************************************************************************************************************/ -import { useAuth } from '@/contexts/AuthContext' -import { useNavigate, Link } from 'react-router-dom' + * File Name: LoginForm.jsx + * Type of Program: Frontend Component + * Description: Login form using react-hook-form + Zod (no shadcn Form wrapper). + * Supports system login and Google OAuth redirect. + * Module: User Credentials + * Author: lash0000 + * Date Created: Oct. 10, 2025 + *********************************************************************************************************************************************************************** + * Change History: + * DATE AUTHOR LOG NUMBER DESCRIPTION + * Oct. 10, 2025 lash0000 001 Initial creation - STAR Phase 1 Project + * May 23, 2026 lash0000 002 Migrated to Zod + zodResolver; removed shadcn Form wrapper + ***********************************************************************************************************************************************************************/ import { useState } from 'react' +import { useNavigate, Link } from 'react-router-dom' +import { useAuth } from '@/contexts/AuthContext' import { useForm } from 'react-hook-form' import { z } from 'zod' import { zodResolver } from '@hookform/resolvers/zod' @@ -20,176 +22,186 @@ import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Field, FieldDescription, FieldGroup, FieldLabel, FieldSeparator, } from '@/components/ui/field' -import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog' +import { Separator } from '@/components/ui/separator' +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { Eye, EyeOff, LoaderCircle } from 'lucide-react' // ─── Schema ────────────────────────────────────────────────────────────────── const loginSchema = z.object({ - email: z.string().email('Invalid email address'), - password: z.string().min(1, 'Password is required'), + email: z.string().email('Invalid email address'), + password: z.string().min(1, 'Password is required'), }) // ─── Component ─────────────────────────────────────────────────────────────── export function LoginForm({ className, ...props }) { - const { login } = useAuth() - const navigate = useNavigate() + const { login } = useAuth() + const navigate = useNavigate() - const [passwordVisible, setPasswordVisible] = useState(false) - const [errorDialogOpen, setErrorDialogOpen] = useState(false) - const [errorMessage, setErrorMessage] = useState('') + const [passwordVisible, setPasswordVisible] = useState(false) + const [errorDialogOpen, setErrorDialogOpen] = useState(false) + const [errorMessage, setErrorMessage] = useState('') - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(loginSchema), - defaultValues: { email: '', password: '' }, - }) + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { email: '', password: '' }, + }) - const onSubmit = async ({ email, password }) => { - const result = await login({ email, password }) + const onSubmit = async ({ email, password }) => { + const result = await login({ email, password }) - if (result.success) { - switch (result.user.acc_type) { - case 'admin': navigate('/admin'); break - case 'staff': navigate('/staff'); break - case 'client': navigate('/client'); break - default: navigate('/login') - } - return - } - - setErrorMessage(result.message || 'Invalid credentials') - setErrorDialogOpen(true) + if (result.success) { + switch (result.user.acc_type) { + case 'admin': navigate('/admin'); break + case 'staff': navigate('/staff'); break + case 'client': navigate('/client'); break + default: navigate('/login') + } + return } - const handleGoogle = () => { - window.location.href = '/api/auth/google' - } + setErrorMessage(result.message || 'Invalid credentials.') + setErrorDialogOpen(true) + } - return ( - <> -
{ + window.location.href = '/api/auth/google' + } + + return ( + <> + + {/* Header */} +
+

+ Take the next step towards new learnings. +

+

+ Learn and grow for your career. +

+
+ + {/* Google OAuth */} + + +
+ + Or continue with + +
+ + {/* Email */} +
+ + e.target.removeAttribute('readonly')} + {...register('email')} + /> + {errors.email && ( +

{errors.email.message}

+ )} +
+ + {/* Password */} +
+
+ + - -
-

- Take the next step towards new learnings. -

-

- Learn and grow for your career. -

-
+ Forgot password? + +
+
+ e.target.removeAttribute('readonly')} + {...register('password')} + /> + +
+ {errors.password && ( +

{errors.password.message}

+ )} +
- - - + {/* Submit */} + - - Or continue with - +

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

+
- {/* Email */} - - Email - e.target.removeAttribute('readonly')} - {...register('email')} - /> - {errors.email && ( -

{errors.email.message}

- )} -
- - {/* Password */} - -
- Password - - Forgot Password? - -
-
- e.target.removeAttribute('readonly')} - {...register('password')} - /> - -
- {errors.password && ( -

{errors.password.message}

- )} -
- - {/* Submit */} - - - - - - - Don't have an account?{' '} - Sign up - - - - - - {/* Error Dialog */} - - - - Login failed - {errorMessage} - - - setErrorDialogOpen(false)}> - Okay - - - - - - ) + {/* Error Dialog */} + + + + Login failed + {errorMessage} + + + setErrorDialogOpen(false)}>Okay + + + + + ) } \ No newline at end of file diff --git a/src/modules/auth/components/RegisterForm.jsx b/src/modules/auth/components/RegisterForm.jsx new file mode 100644 index 0000000..0e71588 --- /dev/null +++ b/src/modules/auth/components/RegisterForm.jsx @@ -0,0 +1,691 @@ +/*********************************************************************************************************************************************************************** + * File Name: RegisterForm.jsx + * Type of Program: Frontend Component + * Description: Multi-step registration form with Zod + react-hook-form (no shadcn Form wrapper). + * Step 1 — Personal Info (given name, last name, middle name, extension, birthday, occupation, phone) + * Step 2 — Credentials (email, password, confirm, group_code from URL) + * Step 3 — OTP Verify (6-cell input, resend countdown) + * Reads optional group_code from URL (/register?group_code=XXX). + * Module: User Credentials + * Author: lash0000 + * Date Created: May 23, 2026 + *********************************************************************************************************************************************************************** + * Change History: + * DATE AUTHOR LOG NUMBER DESCRIPTION + * May 23, 2026 lash0000 001 Initial creation - STAR Phase 1 Project + * May 23, 2026 lash0000 002 birthday + occupation required; all calls via useAuth (register, verifyOTP, resendOTP) + ***********************************************************************************************************************************************************************/ +import { useState, useRef, useEffect } from 'react' +import { useNavigate, Link, useSearchParams } from 'react-router-dom' +import { useForm, Controller } from 'react-hook-form' +import { useAuth } from '@/contexts/AuthContext' +import { z } from 'zod' +import { zodResolver } from '@hookform/resolvers/zod' +import { cn } from '@/lib/utils' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Eye, EyeOff, LoaderCircle, Users, Check } from 'lucide-react' + +// ─── Step schemas ───────────────────────────────────────────────────────────── +const personalSchema = z.object({ + given_name: z.string().min(1, 'First name is required').max(50), + last_name: z.string().min(1, 'Last name is required').max(50), + middle_name: z.string().max(50).optional(), + extension_name: z.string().max(10).optional(), + date_of_birth: z + .string() + .min(1, 'Birthday is required') + .refine((v) => !isNaN(Date.parse(v)), { message: 'Invalid date' }) + .refine((v) => { + const age = (Date.now() - new Date(v)) / (1000 * 60 * 60 * 24 * 365.25) + return age >= 13 && age <= 120 + }, { message: 'Must be at least 13 years old' }), + occupation: z.string().min(1, 'Occupation is required').max(100), + phone: z.string().regex(/^\+?[0-9\s\-()]{7,20}$/, 'Invalid phone number').optional().or(z.literal('')), +}) + +const credentialsSchema = z + .object({ + email: z.string().email('Invalid email address'), + password: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex(/[A-Z]/, 'Must contain at least one uppercase letter') + .regex(/[0-9]/, 'Must contain at least one number'), + confirm_password: z.string().min(1, 'Please confirm your password'), + group_code: z.string().optional(), + }) + .refine((d) => d.password === d.confirm_password, { + message: 'Passwords do not match', + path: ['confirm_password'], + }) + +const otpSchema = z.object({ + otp: z + .string() + .length(6, 'Enter all 6 digits') + .regex(/^\d{6}$/, 'OTP must contain only digits'), +}) + +// ─── Stepper indicator ──────────────────────────────────────────────────────── +const STEPS = [ + { label: 'Personal info' }, + { label: 'Credentials' }, + { label: 'Verify email' }, +] + +function StepIndicator({ current }) { + return ( +
+ {STEPS.map((s, i) => { + const done = i < current + const active = i === current + const isLast = i === STEPS.length - 1 + + return ( +
+ {/* Circle */} +
+
+ {done ? : i + 1} +
+ + {s.label} + +
+ + {/* Connector */} + {!isLast && ( +
+ )} +
+ ) + })} +
+ ) +} + +// ─── OTP Cell Input ─────────────────────────────────────────────────────────── +function OtpInput({ value = '', onChange }) { + const cellRefs = Array.from({ length: 6 }, () => useRef(null)) + const digits = value.split('') + + const handleChange = (i, e) => { + const char = e.target.value.replace(/\D/g, '').slice(-1) + const next = [...digits] + next[i] = char + onChange(next.join('')) + if (char && i < 5) cellRefs[i + 1].current?.focus() + } + + const handleKeyDown = (i, e) => { + if (e.key === 'Backspace' && !digits[i] && i > 0) { + const next = [...digits] + next[i - 1] = '' + onChange(next.join('')) + cellRefs[i - 1].current?.focus() + } + } + + const handlePaste = (e) => { + e.preventDefault() + const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6) + onChange(pasted) + cellRefs[Math.min(pasted.length, 5)].current?.focus() + } + + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + handleChange(i, e)} + onKeyDown={(e) => handleKeyDown(i, e)} + className="w-11 h-12 text-center text-lg font-semibold p-0" + /> + ))} +
+ ) +} + +// ─── RegisterForm ───────────────────────────────────────────────────────────── +export function RegisterForm({ className, ...props }) { + const navigate = useNavigate() + const { register: authRegister, verifyOTP, resendOTP } = useAuth() + const [searchParams] = useSearchParams() + const groupCode = searchParams.get('group_code') || '' + + // 0 = personal, 1 = credentials, 2 = otp + const [step, setStep] = useState(0) + const [pendingEmail, setPendingEmail] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [showConfirm, setShowConfirm] = useState(false) + const [resendCooldown, setResendCooldown] = useState(0) + const [errorDialogOpen, setErrorDialogOpen] = useState(false) + const [errorMessage, setErrorMessage] = useState('') + + // Accumulated data across steps + const [personalData, setPersonalData] = useState({}) + + // ── Resend countdown ────────────────────────────────────────────────────── + useEffect(() => { + if (resendCooldown <= 0) return + const t = setTimeout(() => setResendCooldown((c) => c - 1), 1000) + return () => clearTimeout(t) + }, [resendCooldown]) + + // ── Step 1: Personal info ───────────────────────────────────────────────── + const { + register: regPersonal, + handleSubmit: submitPersonal, + formState: { errors: errPersonal }, + } = useForm({ + resolver: zodResolver(personalSchema), + defaultValues: { given_name: '', last_name: '', middle_name: '', extension_name: '', date_of_birth: '', occupation: '', phone: '' }, + }) + + const onPersonalNext = (data) => { + setPersonalData(data) + setStep(1) + } + + // ── Step 2: Credentials ─────────────────────────────────────────────────── + const { + register: regCreds, + handleSubmit: submitCreds, + formState: { errors: errCreds, isSubmitting: isRegistering }, + } = useForm({ + resolver: zodResolver(credentialsSchema), + defaultValues: { email: '', password: '', confirm_password: '', group_code: groupCode }, + }) + + const onCredentialsSubmit = async ({ email, password, group_code }) => { + const result = await authRegister({ + email, + password, + personal_info: { + name: { + given_name: personalData.given_name, + middle_name: personalData.middle_name || '', + last_name: personalData.last_name, + extension_name: personalData.extension_name || '', + full_name: [ + `${personalData.last_name},`, + personalData.given_name, + personalData.middle_name || '', + personalData.extension_name || '', + ].filter(Boolean).join(' ').trim(), + }, + date_of_birth: personalData.date_of_birth, + occupation: personalData.occupation, + phone_number: personalData.phone + ? (() => { + const digits = personalData.phone.replace(/\D/g, '') + const number = digits.startsWith('63') + ? digits.slice(2) // strip country code if user typed +63... + : digits.replace(/^0/, '') // strip leading 0 if user typed 09... + return [{ + number, + country_code: '63', + full_number: `63${number}`, + phone_type: 'mobile', + }] + })() + : [], + addresses: [], + }, + ...(group_code ? { group_code } : {}), + }) + + if (!result.success) { + setErrorMessage(result.message) + setErrorDialogOpen(true) + return + } + + setPendingEmail(email) + setResendCooldown(30) + setStep(2) + } + + // ── Step 3: OTP ─────────────────────────────────────────────────────────── + const { + control: otpControl, + handleSubmit: submitOtp, + reset: resetOtp, + formState: { errors: errOtp, isSubmitting: isVerifying }, + } = useForm({ + resolver: zodResolver(otpSchema), + defaultValues: { otp: '' }, + }) + + const onVerifyOTP = async ({ otp }) => { + const result = await verifyOTP({ email: pendingEmail, otp }) + + if (!result.success) { + setErrorMessage(result.message) + setErrorDialogOpen(true) + return + } + + navigate('/dashboard') + } + + const handleResend = async () => { + if (resendCooldown > 0) return + + const result = await resendOTP({ email: pendingEmail }) + + if (!result.success) { + setErrorMessage(result.message) + setErrorDialogOpen(true) + return + } + + setResendCooldown(30) + resetOtp() + } + + // ───────────────────────────────────────────────────────────────────────── + return ( + <> +
+ + + {/* ── Step 1: Personal info ── */} + {step === 0 && ( +
+
+

Personal information.

+

Tell us a bit about yourself.

+
+ + {/* Given + Last name row */} +
+
+ + + {errPersonal.given_name && ( +

{errPersonal.given_name.message}

+ )} +
+ +
+ + + {errPersonal.last_name && ( +

{errPersonal.last_name.message}

+ )} +
+
+ + {/* Middle name */} +
+ + + {errPersonal.middle_name && ( +

{errPersonal.middle_name.message}

+ )} +
+ + {/* Extension name + Birthday row */} +
+
+ + + {errPersonal.extension_name && ( +

{errPersonal.extension_name.message}

+ )} +
+ +
+ + + {errPersonal.date_of_birth && ( +

{errPersonal.date_of_birth.message}

+ )} +
+
+ + {/* Occupation */} +
+ + + {errPersonal.occupation && ( +

{errPersonal.occupation.message}

+ )} +
+ + {/* Phone */} +
+ + + {errPersonal.phone && ( +

{errPersonal.phone.message}

+ )} +
+ + + +

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

+
+ )} + + {/* ── Step 2: Credentials ── */} + {step === 1 && ( +
+
+

Account credentials.

+

Set your login email and password.

+
+ + {/* Group code notice */} + {groupCode && ( +
+ +

+ Joining group{' '} + {groupCode} +

+
+ )} + + {/* Email */} +
+ + e.target.removeAttribute('readonly')} + {...regCreds('email')} + /> + {errCreds.email && ( +

{errCreds.email.message}

+ )} +
+ + {/* Password */} +
+ +
+ + +
+ {errCreds.password && ( +

{errCreds.password.message}

+ )} +
+ + {/* Confirm password */} +
+ +
+ + +
+ {errCreds.confirm_password && ( +

{errCreds.confirm_password.message}

+ )} +
+ + {/* Group code — disabled, auto-filled from URL */} +
+ + +

+ Provided by your group admin via invite link. +

+
+ + {/* Navigation */} +
+ + +
+ +

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

+
+ )} + + {/* ── Step 3: OTP ── */} + {step === 2 && ( +
+
+

Check your email.

+

+ We sent a 6-digit code to{' '} + {pendingEmail}. + It expires in 10 minutes. +

+
+ +
+ ( + + )} + /> + {errOtp.otp && ( +

{errOtp.otp.message}

+ )} +
+ + + +
+ + {resendCooldown > 0 ? `Resend in ${resendCooldown}s` : "Didn't receive it?"} + + +
+ + + + + + )} +
+ + + + + + {step === 2 ? 'Verification failed' : 'Registration failed'} + + {errorMessage} + + + setErrorDialogOpen(false)}>Okay + + + + + ) +} \ No newline at end of file diff --git a/src/modules/auth/pages/Register.jsx b/src/modules/auth/pages/Register.jsx new file mode 100644 index 0000000..d4a1804 --- /dev/null +++ b/src/modules/auth/pages/Register.jsx @@ -0,0 +1,62 @@ +/*********************************************************************************************************************************************************************** + * File Name: Register.jsx + * Type of Program: Frontend Page + * Description: Registration page. Accepts optional ?group_code= query param to + * auto-enroll new users into a user group on verification. + * Route: /register or /register?group_code=XXXX + * Module: User Credentials + * Author: lash0000 + * Date Created: May 23, 2026 + *********************************************************************************************************************************************************************** + * Change History: + * DATE AUTHOR LOG NUMBER DESCRIPTION + * May 23, 2026 lash0000 001 Initial creation - STAR Phase 1 Project + ***********************************************************************************************************************************************************************/ +import { Link } from 'react-router-dom' +import { RegisterForm } from '../components/RegisterForm' +import { MetadataProvider } from '@/contexts/MetadataContext' + +export default function Register() { + return ( + +
+ {/* ── Left panel ── */} +
+
+ +
+ Philproperties +
+
+ Philproperties +
+ +
+ +
+
+ +
+
+
+ + {/* ── Right panel — hero image ── */} +
+ Philproperties +
+
+
+ ) +} \ No newline at end of file diff --git a/src/modules/auth/routes/AuthRoutes.jsx b/src/modules/auth/routes/AuthRoutes.jsx index f42b2e6..ec7542c 100644 --- a/src/modules/auth/routes/AuthRoutes.jsx +++ b/src/modules/auth/routes/AuthRoutes.jsx @@ -4,6 +4,7 @@ import PublicRoute from '../../../routes/PublicRoute' import LandingLayout from '@/modules/public/layouts/LandingLayout' import LandingPage from '@/modules/public/pages/LandingPage' import Login from '../pages/Login' +import Register from '../pages/Register' export const AuthRoutes = { @@ -15,6 +16,7 @@ export const AuthRoutes = { children: [ { index: true, element: }, { path: "login", element: }, + { path: "signup", element: } ] }, ], diff --git a/src/modules/client/routes/ClientRoutes.jsx b/src/modules/client/routes/ClientRoutes.jsx index 204331f..4d03f63 100644 --- a/src/modules/client/routes/ClientRoutes.jsx +++ b/src/modules/client/routes/ClientRoutes.jsx @@ -2,8 +2,8 @@ import ProtectedRoute from '../../../routes/ProtectedRoute' import Client from '../pages/Client' export const ClientRoutes = { - element: , + element: , children: [ - { path: '/client', element: }, + { path: '/dashboard', element: }, ], } \ No newline at end of file diff --git a/src/routes/PublicRoute.jsx b/src/routes/PublicRoute.jsx index 35d688a..d2bfffb 100644 --- a/src/routes/PublicRoute.jsx +++ b/src/routes/PublicRoute.jsx @@ -9,10 +9,10 @@ export default function PublicRoute() { if (user) { switch (user.acc_type) { - case 'admin': return - case 'client': return - case 'staff': return - default: return + case 'admin': return + case 'user': return + case 'staff': return + default: return } }