mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
adjusted
This commit is contained in:
@@ -27,6 +27,7 @@
|
|||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"nanoid": "^5.1.11",
|
"nanoid": "^5.1.11",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "^9.14.0",
|
||||||
|
|||||||
Generated
+12
@@ -59,6 +59,9 @@ importers:
|
|||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
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:
|
radix-ui:
|
||||||
specifier: ^1.4.3
|
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)
|
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==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
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:
|
qs@6.15.1:
|
||||||
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@@ -5991,6 +5999,10 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
|
qrcode.react@4.2.0(react@19.2.5):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.5
|
||||||
|
|
||||||
qs@6.15.1:
|
qs@6.15.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
|
|||||||
@@ -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:
|
||||||
|
* <OverflowBadges
|
||||||
|
* items={groups} // array of objects (or primitives)
|
||||||
|
* labelKey="group_code" // key used for the badge label
|
||||||
|
* dialogTitleKey="name" // key used for the left side in the dialog row (optional)
|
||||||
|
* keyKey="group_id" // key used as React key
|
||||||
|
* dialogTitle="All groups" // dialog heading (optional)
|
||||||
|
* max={2} // how many badges to show before collapsing (default: 2)
|
||||||
|
* badgeVariant="outline" // shadcn Badge variant (default: "outline")
|
||||||
|
* badgeClassName="font-mono text-xs" // extra classes on each badge (optional)
|
||||||
|
* />
|
||||||
|
*
|
||||||
|
* // Primitive arrays (no keys needed):
|
||||||
|
* <OverflowBadges items={["SALES-A1", "HR-B2", "IT-C3"]} max={2} />
|
||||||
|
***********************************************************************************************************************************************************************/
|
||||||
|
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 <span className="text-muted-foreground text-xs">{emptyText}</span>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
{visible.map((item, i) => (
|
||||||
|
<Badge
|
||||||
|
key={getKey(item, i)}
|
||||||
|
variant={badgeVariant}
|
||||||
|
className={badgeClassName}
|
||||||
|
>
|
||||||
|
{getLabel(item)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{overflow.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setOpen(true); }}
|
||||||
|
className="inline-flex items-center justify-center h-5 px-1.5 rounded-md border border-dashed text-xs text-muted-foreground hover:text-foreground hover:border-foreground transition-colors"
|
||||||
|
>
|
||||||
|
+{overflow.length}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[360px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-2 pt-1">
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const label = getLabel(item);
|
||||||
|
const title = getTitle(item);
|
||||||
|
const isSame = label === title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={getKey(item, i)}
|
||||||
|
className="flex items-center justify-between rounded-lg border px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{title}</span>
|
||||||
|
{!isSame && (
|
||||||
|
<Badge variant={badgeVariant} className={badgeClassName}>
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -117,9 +117,9 @@ export function UserGroupProvider({ children }) {
|
|||||||
|
|
||||||
// ─── POST /api/admin/groups ────────────────────────────────────────────────
|
// ─── POST /api/admin/groups ────────────────────────────────────────────────
|
||||||
const createGroup = useCallback(
|
const createGroup = useCallback(
|
||||||
({ name, description }) =>
|
({ name, description, group_code }) =>
|
||||||
request(async () => {
|
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]);
|
setGroups((prev) => [res.data?.data, ...prev]);
|
||||||
toast.success("Group created successfully.");
|
toast.success("Group created successfully.");
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -129,9 +129,9 @@ export function UserGroupProvider({ children }) {
|
|||||||
|
|
||||||
// ─── PUT /api/admin/groups/:gid ───────────────────────────────────────────
|
// ─── PUT /api/admin/groups/:gid ───────────────────────────────────────────
|
||||||
const updateGroup = useCallback(
|
const updateGroup = useCallback(
|
||||||
(gid, { name, description }) =>
|
(gid, { name, description, group_code }) =>
|
||||||
request(async () => {
|
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) =>
|
setGroups((prev) =>
|
||||||
prev.map((g) => (g.group_id === gid ? { ...g, ...res.data?.data } : g))
|
prev.map((g) => (g.group_id === gid ? { ...g, ...res.data?.data } : g))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const AuthContext = createContext(null)
|
|||||||
export const decodeToken = (token) => JSON.parse(atob(token.split('.')[1]))
|
export const decodeToken = (token) => JSON.parse(atob(token.split('.')[1]))
|
||||||
|
|
||||||
export function AuthProvider({ children }) {
|
export function AuthProvider({ children }) {
|
||||||
const [accessToken, _setAccessToken] = useState(null) // ← renamed to _setAccessToken
|
const [accessToken, _setAccessToken] = useState(null)
|
||||||
const [user, setUser] = useState(null)
|
const [user, setUser] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [sessionRestored, setSessionRestored] = useState(false)
|
const [sessionRestored, setSessionRestored] = useState(false)
|
||||||
@@ -16,12 +16,12 @@ export function AuthProvider({ children }) {
|
|||||||
const isRestoring = useRef(false)
|
const isRestoring = useRef(false)
|
||||||
const accessTokenRef = useRef(null)
|
const accessTokenRef = useRef(null)
|
||||||
|
|
||||||
// ← Single setter that updates both ref and state
|
|
||||||
const setAccessToken = useCallback((token) => {
|
const setAccessToken = useCallback((token) => {
|
||||||
accessTokenRef.current = token
|
accessTokenRef.current = token
|
||||||
_setAccessToken(token)
|
_setAccessToken(token)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── Login ──────────────────────────────────────────────────────────────────
|
||||||
const login = useCallback(async ({ email, password }) => {
|
const login = useCallback(async ({ email, password }) => {
|
||||||
setAuthError(null)
|
setAuthError(null)
|
||||||
try {
|
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 () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await api.post('/auth/logout')
|
await api.post('/auth/logout')
|
||||||
@@ -47,12 +87,13 @@ export function AuthProvider({ children }) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// ── Restore session ────────────────────────────────────────────────────────
|
||||||
const restoreSession = useCallback(async () => {
|
const restoreSession = useCallback(async () => {
|
||||||
if (isRestoring.current) return
|
if (isRestoring.current) return
|
||||||
isRestoring.current = true
|
isRestoring.current = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (accessTokenRef.current) return // already have token, skip
|
if (accessTokenRef.current) return
|
||||||
const { data } = await api.post('/auth/refresh')
|
const { data } = await api.post('/auth/refresh')
|
||||||
setAccessToken(data.data.accessToken)
|
setAccessToken(data.data.accessToken)
|
||||||
setUser(data.data.user)
|
setUser(data.data.user)
|
||||||
@@ -60,18 +101,21 @@ export function AuthProvider({ children }) {
|
|||||||
setAccessToken(null)
|
setAccessToken(null)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false) // ← set once, never back to true
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{
|
<AuthContext.Provider value={{
|
||||||
accessToken,
|
accessToken,
|
||||||
accessTokenRef, // ← expose ref for interceptor
|
accessTokenRef,
|
||||||
setAccessToken,
|
setAccessToken,
|
||||||
user,
|
user,
|
||||||
setUser,
|
setUser,
|
||||||
login,
|
login,
|
||||||
|
register,
|
||||||
|
verifyOTP,
|
||||||
|
resendOTP,
|
||||||
logout,
|
logout,
|
||||||
loading,
|
loading,
|
||||||
sessionRestored,
|
sessionRestored,
|
||||||
|
|||||||
@@ -19,16 +19,24 @@ import { Label } from "@/components/ui/label";
|
|||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(1, "Name is required."),
|
name: z.string().min(1, "Name is required."),
|
||||||
description: z.string().min(1, "Description is required."),
|
description: z.string().min(1, "Description is required."),
|
||||||
|
group_code: z
|
||||||
|
.string()
|
||||||
|
.max(50, "Group code must be 50 characters or less.")
|
||||||
|
.regex(/^[A-Z0-9\-]*$/, "Only uppercase letters, numbers, and hyphens allowed.")
|
||||||
|
.optional()
|
||||||
|
.or(z.literal("")),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dialog for creating a new group.
|
* Dialog for creating a new group.
|
||||||
* Matches context: createGroup({ name, description })
|
* Matches context: createGroup({ name, description, group_code })
|
||||||
|
*
|
||||||
|
* group_code is optional — if left blank the backend auto-generates one.
|
||||||
*
|
*
|
||||||
* @param {Object} props
|
* @param {Object} props
|
||||||
* @param {boolean} props.open
|
* @param {boolean} props.open
|
||||||
* @param {Function} props.onOpenChange
|
* @param {Function} props.onOpenChange
|
||||||
* @param {Function} props.onSubmit Called with { name, description }
|
* @param {Function} props.onSubmit Called with { name, description, group_code? }
|
||||||
* @param {boolean} [props.loading]
|
* @param {boolean} [props.loading]
|
||||||
*/
|
*/
|
||||||
export function AddGroupDialog({ open, onOpenChange, onSubmit, loading }) {
|
export function AddGroupDialog({ open, onOpenChange, onSubmit, loading }) {
|
||||||
@@ -36,14 +44,21 @@ export function AddGroupDialog({ open, onOpenChange, onSubmit, loading }) {
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm({
|
} = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: { name: "", description: "" },
|
defaultValues: { name: "", description: "", group_code: "" },
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onValid(values) {
|
async function onValid({ name, description, group_code }) {
|
||||||
await onSubmit(values);
|
await onSubmit({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
// omit group_code entirely if blank — backend will auto-generate
|
||||||
|
...(group_code ? { group_code } : {}),
|
||||||
|
});
|
||||||
reset();
|
reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +67,13 @@ export function AddGroupDialog({ open, onOpenChange, onSubmit, loading }) {
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-uppercase as user types
|
||||||
|
function handleGroupCodeChange(e) {
|
||||||
|
setValue("group_code", e.target.value.toUpperCase().replace(/[^A-Z0-9\-]/g, ""), {
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-[440px]">
|
<DialogContent className="sm:max-w-[440px]">
|
||||||
@@ -90,13 +112,24 @@ export function AddGroupDialog({ open, onOpenChange, onSubmit, loading }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="group_code" className="flex items-center gap-2">
|
||||||
|
Group code
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">(auto-generated if blank)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="group_code"
|
||||||
|
placeholder="e.g. SALES-2025"
|
||||||
|
{...register("group_code")}
|
||||||
|
onChange={handleGroupCodeChange}
|
||||||
|
/>
|
||||||
|
{errors.group_code && (
|
||||||
|
<p className="text-sm text-destructive">{errors.group_code.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
|
|||||||
@@ -19,20 +19,25 @@ import { Label } from "@/components/ui/label";
|
|||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(1, "Name is required."),
|
name: z.string().min(1, "Name is required."),
|
||||||
description: z.string().min(1, "Description is required."),
|
description: z.string().min(1, "Description is required."),
|
||||||
|
group_code: z
|
||||||
|
.string()
|
||||||
|
.min(1, "Group code is required.")
|
||||||
|
.max(50, "Group code must be 50 characters or less.")
|
||||||
|
.regex(/^[A-Z0-9\-]+$/, "Only uppercase letters, numbers, and hyphens allowed."),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dialog for editing a group's name and description.
|
* Dialog for editing a group's name, description, and group_code.
|
||||||
* Matches context: updateGroup(gid, { name, description })
|
* Matches context: updateGroup(gid, { name, description, group_code })
|
||||||
*
|
*
|
||||||
* `group` is the full row object from the table — pre-fills name + description.
|
* `group` is the full row object from the table — pre-fills all fields.
|
||||||
* `values` re-syncs whenever `group` prop changes.
|
* `values` re-syncs whenever `group` prop changes.
|
||||||
*
|
*
|
||||||
* @param {Object} props
|
* @param {Object} props
|
||||||
* @param {boolean} props.open
|
* @param {boolean} props.open
|
||||||
* @param {Function} props.onOpenChange
|
* @param {Function} props.onOpenChange
|
||||||
* @param {Object} props.group Row object: { group_id, name, description, ... }
|
* @param {Object} props.group Row object: { group_id, name, description, group_code, ... }
|
||||||
* @param {Function} props.onSubmit Called with { name, description }
|
* @param {Function} props.onSubmit Called with { name, description, group_code }
|
||||||
* @param {boolean} [props.loading]
|
* @param {boolean} [props.loading]
|
||||||
*/
|
*/
|
||||||
export function EditGroupDialog({ open, onOpenChange, group, onSubmit, loading }) {
|
export function EditGroupDialog({ open, onOpenChange, group, onSubmit, loading }) {
|
||||||
@@ -40,12 +45,14 @@ export function EditGroupDialog({ open, onOpenChange, group, onSubmit, loading }
|
|||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
|
setValue,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm({
|
} = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
values: {
|
values: {
|
||||||
name: group?.name ?? "",
|
name: group?.name ?? "",
|
||||||
description: group?.description ?? "",
|
description: group?.description ?? "",
|
||||||
|
group_code: group?.group_code ?? "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,6 +65,13 @@ export function EditGroupDialog({ open, onOpenChange, group, onSubmit, loading }
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-uppercase as user types
|
||||||
|
function handleGroupCodeChange(e) {
|
||||||
|
setValue("group_code", e.target.value.toUpperCase().replace(/[^A-Z0-9\-]/g, ""), {
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-[440px]">
|
<DialogContent className="sm:max-w-[440px]">
|
||||||
@@ -94,13 +108,23 @@ export function EditGroupDialog({ open, onOpenChange, group, onSubmit, loading }
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="edit-group_code">
|
||||||
|
Group code <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="edit-group_code"
|
||||||
|
placeholder="e.g. SALES-2025"
|
||||||
|
{...register("group_code")}
|
||||||
|
onChange={handleGroupCodeChange}
|
||||||
|
/>
|
||||||
|
{errors.group_code && (
|
||||||
|
<p className="text-sm text-destructive">{errors.group_code.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleClose}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
|
|||||||
@@ -4,12 +4,27 @@
|
|||||||
import { buildColumns } from "@/utils/table.util";
|
import { buildColumns } from "@/utils/table.util";
|
||||||
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||||
|
import { OverflowBadges } from "@/components/generic/OverflowBadges";
|
||||||
|
|
||||||
export const columnPinning = {
|
export const columnPinning = {
|
||||||
right: ["actions"],
|
right: ["actions"],
|
||||||
left: [],
|
left: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
||||||
|
const cellOverrides = {
|
||||||
|
groups: (info) => (
|
||||||
|
<OverflowBadges
|
||||||
|
items={info.getValue() ?? []}
|
||||||
|
keyKey="group_id"
|
||||||
|
labelKey="group_code"
|
||||||
|
dialogTitleKey="name"
|
||||||
|
dialogTitle="All groups"
|
||||||
|
badgeClassName="text-xs font-mono"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the full column array for the Users table.
|
* Builds the full column array for the Users table.
|
||||||
*
|
*
|
||||||
@@ -22,7 +37,7 @@ export function buildDataColumns(attributes, rowActions) {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
buildSelectionColumn(),
|
buildSelectionColumn(),
|
||||||
...buildColumns(visibleAttributes),
|
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||||
buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }),
|
buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,21 @@
|
|||||||
|
|
||||||
import { useRef, useMemo, useState, useEffect, useCallback } from "react";
|
import { useRef, useMemo, useState, useEffect, useCallback } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { House, Users } from "lucide-react";
|
import { House, Users, QrCode, Download, Copy, Check, Link } from "lucide-react";
|
||||||
|
import { QRCodeCanvas } from "qrcode.react";
|
||||||
|
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
import DataTable from "@/components/generic/Table/DataTable";
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
||||||
import { AddSheet } from "@/components/generic/Sheet/AddSheet";
|
import { AddSheet } from "@/components/generic/Sheet/AddSheet";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
import { useUserGroups } from "@/contexts/AdminUserGroupContext";
|
import { useUserGroups } from "@/contexts/AdminUserGroupContext";
|
||||||
|
|
||||||
@@ -19,20 +27,113 @@ import { buildRowActions } from "../../config/user_groups/view/rowActions.config
|
|||||||
|
|
||||||
import { getTimestamp } from "@/utils/timestamp.util";
|
import { getTimestamp } from "@/utils/timestamp.util";
|
||||||
|
|
||||||
|
// ─── Invite Link Dialog ───────────────────────────────────────────────────────
|
||||||
|
function InviteLinkDialog({ open, onOpenChange, group }) {
|
||||||
|
const [copiedLink, setCopiedLink] = useState(false);
|
||||||
|
const [copiedCode, setCopiedCode] = useState(false);
|
||||||
|
|
||||||
|
const inviteUrl = group?.group_code
|
||||||
|
? `${window.location.origin}/signup?group_code=${group.group_code}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleCopyLink = async () => {
|
||||||
|
if (!inviteUrl) return;
|
||||||
|
await navigator.clipboard.writeText(inviteUrl);
|
||||||
|
setCopiedLink(true);
|
||||||
|
setTimeout(() => setCopiedLink(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyCode = async () => {
|
||||||
|
if (!group?.group_code) return;
|
||||||
|
await navigator.clipboard.writeText(group.group_code);
|
||||||
|
setCopiedCode(true);
|
||||||
|
setTimeout(() => setCopiedCode(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const canvas = document.querySelector("[data-qr='group-invite']");
|
||||||
|
if (!canvas) return;
|
||||||
|
const url = canvas.toDataURL("image/png");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `QR_${group.group_code}.png`;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[420px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Invite link</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-5 py-2">
|
||||||
|
|
||||||
|
{/* QR code */}
|
||||||
|
<div className="rounded-xl border bg-white p-4 shadow-sm">
|
||||||
|
{inviteUrl && (
|
||||||
|
<QRCodeCanvas
|
||||||
|
data-qr="group-invite"
|
||||||
|
value={inviteUrl}
|
||||||
|
size={180}
|
||||||
|
includeMargin={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Download QR */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full gap-2"
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
<Download className="size-4" />
|
||||||
|
Download QR code
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="w-full flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<div className="flex-1 h-px bg-border" />
|
||||||
|
or share the link
|
||||||
|
<div className="flex-1 h-px bg-border" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite link */}
|
||||||
|
<div className="w-full flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border bg-muted px-3 py-2 min-w-0">
|
||||||
|
<p className="font-mono text-xs text-muted-foreground truncate flex-1">
|
||||||
|
{inviteUrl}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button className="w-full gap-2" onClick={handleCopyLink}>
|
||||||
|
{copiedLink
|
||||||
|
? <><Check className="size-4" /> Copied!</>
|
||||||
|
: <><Copy className="size-4" /> Copy invite link</>
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
export default function ViewGroup() {
|
export default function ViewGroup() {
|
||||||
const { groupId } = useParams();
|
const { groupId } = useParams();
|
||||||
|
|
||||||
const tableRefsRef = useRef({
|
const tableRefsRef = useRef({
|
||||||
getFilters: () => [],
|
getFilters: () => [],
|
||||||
getSort: () => [],
|
getSort: () => [],
|
||||||
resetSelection: () => { },
|
resetSelection: () => {},
|
||||||
setFilters: () => { },
|
setFilters: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
||||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
const [inviteOpen, setInviteOpen] = useState(false);
|
||||||
const [archiveIds, setArchiveIds] = useState(null);
|
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||||
const [memberAttrs, setMemberAttrs] = useState([]);
|
const [archiveIds, setArchiveIds] = useState(null);
|
||||||
|
const [memberAttrs, setMemberAttrs] = useState([]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
group,
|
group,
|
||||||
@@ -57,14 +158,14 @@ export default function ViewGroup() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRefsReady = (refs) => {
|
const handleRefsReady = (refs) => {
|
||||||
tableRefsRef.current = refs; // ← just store refs directly, nothing else needed
|
tableRefsRef.current = refs;
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportConfig = {
|
const exportConfig = {
|
||||||
allData: members,
|
allData: members,
|
||||||
attributes: memberAttrs,
|
attributes: memberAttrs,
|
||||||
filename: `${getTimestamp()}_Group_${groupId}_Members`,
|
filename: `${getTimestamp()}_Group_${groupId}_Members`,
|
||||||
sheetName: "Members",
|
sheetName: "Members",
|
||||||
};
|
};
|
||||||
|
|
||||||
const rowActions = buildRowActions({
|
const rowActions = buildRowActions({
|
||||||
@@ -77,13 +178,13 @@ export default function ViewGroup() {
|
|||||||
pagination,
|
pagination,
|
||||||
exportConfig,
|
exportConfig,
|
||||||
onAddMember: () => setAddMemberOpen(true),
|
onAddMember: () => setAddMemberOpen(true),
|
||||||
getFilters: () => tableRefsRef.current.getFilters(),
|
getFilters: () => tableRefsRef.current.getFilters(),
|
||||||
getSort: () => tableRefsRef.current.getSort(),
|
getSort: () => tableRefsRef.current.getSort(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectionActions = buildSelectionActions({
|
const selectionActions = buildSelectionActions({
|
||||||
exportConfig,
|
exportConfig,
|
||||||
onRemoveMember: (row) => setArchiveTarget(row),
|
onRemoveMember: (row) => setArchiveTarget(row),
|
||||||
onRemoveMembers: (ids) => setArchiveIds(ids),
|
onRemoveMembers: (ids) => setArchiveIds(ids),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,26 +201,26 @@ export default function ViewGroup() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "User Groups", to: "/admin/groups" },
|
{ label: "User Groups", to: "/admin/groups" },
|
||||||
{ label: group?.name ?? "View Group" },
|
{ label: group?.name ?? "View Group" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const formattedCreated = group?.createdAt
|
const formattedCreated = group?.createdAt
|
||||||
? new Date(group.createdAt).toLocaleDateString("en-PH", {
|
? new Date(group.createdAt).toLocaleDateString("en-PH", {
|
||||||
year: "numeric", month: "long", day: "numeric",
|
year: "numeric", month: "long", day: "numeric",
|
||||||
})
|
})
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
const formattedUpdated = group?.updatedAt
|
const formattedUpdated = group?.updatedAt
|
||||||
? new Date(group.updatedAt).toLocaleDateString("en-PH", {
|
? new Date(group.updatedAt).toLocaleDateString("en-PH", {
|
||||||
year: "numeric", month: "long", day: "numeric",
|
year: "numeric", month: "long", day: "numeric",
|
||||||
})
|
})
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
const handleFetch = useCallback(
|
const handleFetch = useCallback(
|
||||||
(params) => fetchGroup(groupId, params),
|
(params) => fetchGroup(groupId, params),
|
||||||
[groupId] // fetchGroup should be useCallback'd in context
|
[groupId]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -132,32 +233,48 @@ export default function ViewGroup() {
|
|||||||
|
|
||||||
<div className="w-full flex flex-col gap-4 pb-8">
|
<div className="w-full flex flex-col gap-4 pb-8">
|
||||||
|
|
||||||
{/* ── Group detail card ─────────────────────────────────────────── */}
|
{/* ── Group detail card ──────────────────────────────────────────── */}
|
||||||
<div className="bg-card border rounded-xl p-5 flex flex-col gap-4">
|
<div className="bg-card border rounded-xl p-5 flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-lg font-medium leading-none">
|
<div className="flex items-center gap-2">
|
||||||
{group?.name ?? "—"}
|
<h1 className="text-lg font-medium leading-none">
|
||||||
</h1>
|
{group?.name ?? "—"}
|
||||||
<span
|
</h1>
|
||||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${group?.is_active
|
<span
|
||||||
? "bg-green-100 text-green-700"
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
: "bg-red-100 text-red-600"
|
group?.is_active
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: "bg-red-100 text-red-600"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{group?.is_active ? "Active" : "Inactive"}
|
{group?.is_active ? "Active" : "Inactive"}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{group?.description ?? "—"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{group?.description ?? "—"}
|
{/* ── Generate invite link button ── */}
|
||||||
</p>
|
{group?.group_code && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0 gap-1.5"
|
||||||
|
onClick={() => setInviteOpen(true)}
|
||||||
|
>
|
||||||
|
<QrCode className="size-4" />
|
||||||
|
Invite link
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
{[
|
{[
|
||||||
{ label: "Group ID", value: group?.group_id ? `#${group.group_id}` : "—" },
|
{ label: "Group Code", value: group?.group_code ?? "—" },
|
||||||
{ label: "Members", value: pagination?.totalRecords ?? 0, icon: <Users className="size-3.5 text-muted-foreground" /> },
|
{ label: "Members", value: pagination?.totalRecords ?? 0, icon: <Users className="size-3.5 text-muted-foreground" /> },
|
||||||
{ label: "Created", value: formattedCreated },
|
{ label: "Created", value: formattedCreated },
|
||||||
{ label: "Last Updated", value: formattedUpdated },
|
{ label: "Last Updated", value: formattedUpdated },
|
||||||
].map(({ label, value, icon }) => (
|
].map(({ label, value, icon }) => (
|
||||||
<div key={label} className="bg-muted rounded-lg px-3 py-2">
|
<div key={label} className="bg-muted rounded-lg px-3 py-2">
|
||||||
@@ -172,7 +289,7 @@ export default function ViewGroup() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Members table ─────────────────────────────────────────────── */}
|
{/* ── Members table ──────────────────────────────────────────────── */}
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<DataTable
|
<DataTable
|
||||||
title="Members"
|
title="Members"
|
||||||
@@ -206,7 +323,14 @@ export default function ViewGroup() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Add member — generic sheet ───────────────────────────────────── */}
|
{/* ── Invite link dialog ────────────────────────────────────────────── */}
|
||||||
|
<InviteLinkDialog
|
||||||
|
open={inviteOpen}
|
||||||
|
onOpenChange={setInviteOpen}
|
||||||
|
group={group}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Add member ───────────────────────────────────────────────────── */}
|
||||||
<AddSheet
|
<AddSheet
|
||||||
open={addMemberOpen}
|
open={addMemberOpen}
|
||||||
onOpenChange={setAddMemberOpen}
|
onOpenChange={setAddMemberOpen}
|
||||||
@@ -223,25 +347,25 @@ export default function ViewGroup() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Single remove ────────────────────────────────────────────────── */}
|
{/* ── Single remove ─────────────────────────────────────────────────── */}
|
||||||
<ArchiveDialog
|
<ArchiveDialog
|
||||||
open={!!archiveTarget}
|
open={!!archiveTarget}
|
||||||
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||||
entity={archiveTarget}
|
entity={archiveTarget}
|
||||||
entityLabel="Member"
|
entityLabel="Member"
|
||||||
getName={(m) => m?.personal_info?.name?.full_name ?? m?.email}
|
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}
|
loading={loading}
|
||||||
onSuccess={handleRemoveSuccess}
|
onSuccess={handleRemoveSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Bulk remove ──────────────────────────────────────────────────── */}
|
{/* ── Bulk remove ───────────────────────────────────────────────────── */}
|
||||||
<ArchiveDialog
|
<ArchiveDialog
|
||||||
open={!!archiveIds}
|
open={!!archiveIds}
|
||||||
onOpenChange={(v) => !v && setArchiveIds(null)}
|
onOpenChange={(v) => !v && setArchiveIds(null)}
|
||||||
ids={archiveIds ?? []}
|
ids={archiveIds ?? []}
|
||||||
entityLabel="Member"
|
entityLabel="Member"
|
||||||
onArchive={({ ids }) => removeUsersFromGroup(groupId, ids)} // ← destructure { ids }
|
onArchive={({ ids }) => removeUsersFromGroup(groupId, ids)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRemoveSuccess}
|
onSuccess={handleRemoveSuccess}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
/***********************************************************************************************************************************************************************
|
/***********************************************************************************************************************************************************************
|
||||||
* File Name: login-form.jsx
|
* File Name: LoginForm.jsx
|
||||||
* Type of Program: Frontend Layout
|
* Type of Program: Frontend Component
|
||||||
* Description: Frontend layout Login Page.
|
* Description: Login form using react-hook-form + Zod (no shadcn Form wrapper).
|
||||||
* Module: User Credentials
|
* Supports system login and Google OAuth redirect.
|
||||||
* Author: lash0000
|
* Module: User Credentials
|
||||||
* Date Created: Oct. 10, 2025
|
* Author: lash0000
|
||||||
***********************************************************************************************************************************************************************
|
* Date Created: Oct. 10, 2025
|
||||||
* Change History:
|
***********************************************************************************************************************************************************************
|
||||||
* DATE AUTHOR LOG NUMBER DESCRIPTION
|
* Change History:
|
||||||
* Oct. 10, 2025 lash0000 001 Initial creation - STAR Phase 1 Project
|
* DATE AUTHOR LOG NUMBER DESCRIPTION
|
||||||
***********************************************************************************************************************************************************************/
|
* Oct. 10, 2025 lash0000 001 Initial creation - STAR Phase 1 Project
|
||||||
import { useAuth } from '@/contexts/AuthContext'
|
* May 23, 2026 lash0000 002 Migrated to Zod + zodResolver; removed shadcn Form wrapper
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
***********************************************************************************************************************************************************************/
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { useAuth } from '@/contexts/AuthContext'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
@@ -20,176 +22,186 @@ import { cn } from '@/lib/utils'
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Field, FieldDescription, FieldGroup, FieldLabel, FieldSeparator, } from '@/components/ui/field'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
import { Eye, EyeOff, LoaderCircle } from 'lucide-react'
|
import { Eye, EyeOff, LoaderCircle } from 'lucide-react'
|
||||||
|
|
||||||
// ─── Schema ──────────────────────────────────────────────────────────────────
|
// ─── Schema ──────────────────────────────────────────────────────────────────
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email('Invalid email address'),
|
email: z.string().email('Invalid email address'),
|
||||||
password: z.string().min(1, 'Password is required'),
|
password: z.string().min(1, 'Password is required'),
|
||||||
})
|
})
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
export function LoginForm({ className, ...props }) {
|
export function LoginForm({ className, ...props }) {
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [passwordVisible, setPasswordVisible] = useState(false)
|
const [passwordVisible, setPasswordVisible] = useState(false)
|
||||||
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
|
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm({
|
} = useForm({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
defaultValues: { email: '', password: '' },
|
defaultValues: { email: '', password: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const onSubmit = async ({ email, password }) => {
|
const onSubmit = async ({ email, password }) => {
|
||||||
const result = await login({ email, password })
|
const result = await login({ email, password })
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
switch (result.user.acc_type) {
|
switch (result.user.acc_type) {
|
||||||
case 'admin': navigate('/admin'); break
|
case 'admin': navigate('/admin'); break
|
||||||
case 'staff': navigate('/staff'); break
|
case 'staff': navigate('/staff'); break
|
||||||
case 'client': navigate('/client'); break
|
case 'client': navigate('/client'); break
|
||||||
default: navigate('/login')
|
default: navigate('/login')
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
|
||||||
|
|
||||||
setErrorMessage(result.message || 'Invalid credentials')
|
|
||||||
setErrorDialogOpen(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGoogle = () => {
|
setErrorMessage(result.message || 'Invalid credentials.')
|
||||||
window.location.href = '/api/auth/google'
|
setErrorDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const handleGoogle = () => {
|
||||||
<>
|
window.location.href = '/api/auth/google'
|
||||||
<form
|
}
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
className={cn('flex flex-col gap-6', className)}
|
return (
|
||||||
{...props}
|
<>
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
className={cn('flex flex-col gap-6', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tighter">
|
||||||
|
Take the next step towards new learnings.
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm text-balance">
|
||||||
|
Learn and grow for your career.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Google OAuth */}
|
||||||
|
<Button variant="outline" type="button" onClick={handleGoogle} className="w-full">
|
||||||
|
<svg viewBox="0 0 128 128" className="size-4 mr-2">
|
||||||
|
<path fill="#fff" d="M44.59 4.21a63.28 63.28 0 004.33 120.9 67.6 67.6 0 0032.36.35 57.13 57.13 0 0025.9-13.46 57.44 57.44 0 0016-26.26 74.33 74.33 0 001.61-33.58H65.27v24.69h34.47a29.72 29.72 0 01-12.66 19.52 36.16 36.16 0 01-13.93 5.5 41.29 41.29 0 01-15.1 0A37.16 37.16 0 0144 95.74a39.3 39.3 0 01-14.5-19.42 38.31 38.31 0 010-24.63 39.25 39.25 0 019.18-14.91A37.17 37.17 0 0176.13 27a34.28 34.28 0 0113.64 8q5.83-5.8 11.64-11.63c2-2.09 4.18-4.08 6.15-6.22A61.22 61.22 0 0087.2 4.59a64 64 0 00-42.61-.38z" />
|
||||||
|
<path fill="#e33629" d="M44.59 4.21a64 64 0 0142.61.37 61.22 61.22 0 0120.35 12.62c-2 2.14-4.11 4.14-6.15 6.22Q95.58 29.23 89.77 35a34.28 34.28 0 00-13.64-8 37.17 37.17 0 00-37.46 9.74 39.25 39.25 0 00-9.18 14.91L8.76 35.6A63.53 63.53 0 0144.59 4.21z" />
|
||||||
|
<path fill="#f8bd00" d="M3.26 51.5a62.93 62.93 0 015.5-15.9l20.73 16.09a38.31 38.31 0 000 24.63q-10.36 8-20.73 16.08a63.33 63.33 0 01-5.5-40.9z" />
|
||||||
|
<path fill="#587dbd" d="M65.27 52.15h59.52a74.33 74.33 0 01-1.61 33.58 57.44 57.44 0 01-16 26.26c-6.69-5.22-13.41-10.4-20.1-15.62a29.72 29.72 0 0012.66-19.54H65.27c-.01-8.22 0-16.45 0-24.68z" />
|
||||||
|
<path fill="#319f43" d="M8.75 92.4q10.37-8 20.73-16.08A39.3 39.3 0 0044 95.74a37.16 37.16 0 0014.08 6.08 41.29 41.29 0 0015.1 0 36.16 36.16 0 0013.93-5.5c6.69 5.22 13.41 10.4 20.1 15.62a57.13 57.13 0 01-25.9 13.47 67.6 67.6 0 01-32.36-.35 63 63 0 01-23-11.59A63.73 63.73 0 018.75 92.4z" />
|
||||||
|
</svg>
|
||||||
|
Login with Google
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
<span>Or continue with</span>
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="email" className="text-sm font-medium">Email</label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
readOnly
|
||||||
|
onFocus={(e) => e.target.removeAttribute('readonly')}
|
||||||
|
{...register('email')}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label htmlFor="password" className="text-sm font-medium">Password</label>
|
||||||
|
<Link
|
||||||
|
to="/forgot-password"
|
||||||
|
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
<FieldGroup>
|
Forgot password?
|
||||||
<div className="flex flex-col items-start gap-1">
|
</Link>
|
||||||
<h1 className="text-2xl font-bold tracking-tighter">
|
</div>
|
||||||
Take the next step towards new learnings.
|
<div className="relative">
|
||||||
</h1>
|
<Input
|
||||||
<p className="text-muted-foreground text-sm text-balance">
|
id="password"
|
||||||
Learn and grow for your career.
|
type={passwordVisible ? 'text' : 'password'}
|
||||||
</p>
|
placeholder="Enter your password"
|
||||||
</div>
|
autoComplete="off"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="pr-10"
|
||||||
|
readOnly
|
||||||
|
onFocus={(e) => e.target.removeAttribute('readonly')}
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setPasswordVisible((v) => !v)}
|
||||||
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{passwordVisible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Field>
|
{/* Submit */}
|
||||||
<Button variant="outline" type="button" onClick={handleGoogle}>
|
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||||
<svg viewBox="0 0 128 128" >
|
{isSubmitting ? (
|
||||||
<path fill="#fff" d="M44.59 4.21a63.28 63.28 0 004.33 120.9 67.6 67.6 0 0032.36.35 57.13 57.13 0 0025.9-13.46 57.44 57.44 0 0016-26.26 74.33 74.33 0 001.61-33.58H65.27v24.69h34.47a29.72 29.72 0 01-12.66 19.52 36.16 36.16 0 01-13.93 5.5 41.29 41.29 0 01-15.1 0A37.16 37.16 0 0144 95.74a39.3 39.3 0 01-14.5-19.42 38.31 38.31 0 010-24.63 39.25 39.25 0 019.18-14.91A37.17 37.17 0 0176.13 27a34.28 34.28 0 0113.64 8q5.83-5.8 11.64-11.63c2-2.09 4.18-4.08 6.15-6.22A61.22 61.22 0 0087.2 4.59a64 64 0 00-42.61-.38z" /><path fill="#e33629" d="M44.59 4.21a64 64 0 0142.61.37 61.22 61.22 0 0120.35 12.62c-2 2.14-4.11 4.14-6.15 6.22Q95.58 29.23 89.77 35a34.28 34.28 0 00-13.64-8 37.17 37.17 0 00-37.46 9.74 39.25 39.25 0 00-9.18 14.91L8.76 35.6A63.53 63.53 0 0144.59 4.21z" /><path fill="#f8bd00" d="M3.26 51.5a62.93 62.93 0 015.5-15.9l20.73 16.09a38.31 38.31 0 000 24.63q-10.36 8-20.73 16.08a63.33 63.33 0 01-5.5-40.9z" /><path fill="#587dbd" d="M65.27 52.15h59.52a74.33 74.33 0 01-1.61 33.58 57.44 57.44 0 01-16 26.26c-6.69-5.22-13.41-10.4-20.1-15.62a29.72 29.72 0 0012.66-19.54H65.27c-.01-8.22 0-16.45 0-24.68z" /><path fill="#319f43" d="M8.75 92.4q10.37-8 20.73-16.08A39.3 39.3 0 0044 95.74a37.16 37.16 0 0014.08 6.08 41.29 41.29 0 0015.1 0 36.16 36.16 0 0013.93-5.5c6.69 5.22 13.41 10.4 20.1 15.62a57.13 57.13 0 01-25.9 13.47 67.6 67.6 0 01-32.36-.35 63 63 0 01-23-11.59A63.73 63.73 0 018.75 92.4z" />
|
<span className="flex items-center gap-2">
|
||||||
</svg>
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
Login with Google
|
Logging in...
|
||||||
</Button>
|
</span>
|
||||||
</Field>
|
) : (
|
||||||
|
'Login'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<FieldSeparator className="flex items-center my-1.5 h-px">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
Or continue with
|
Don't have an account?{' '}
|
||||||
</FieldSeparator>
|
<Link to="/signup" className="font-medium underline underline-offset-4">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
{/* Email */}
|
{/* Error Dialog */}
|
||||||
<Field>
|
<AlertDialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
|
||||||
<FieldLabel htmlFor="email">Email</FieldLabel>
|
<AlertDialogContent>
|
||||||
<Input
|
<AlertDialogHeader>
|
||||||
id="email"
|
<AlertDialogTitle>Login failed</AlertDialogTitle>
|
||||||
type="email"
|
<AlertDialogDescription>{errorMessage}</AlertDialogDescription>
|
||||||
placeholder="Enter your email"
|
</AlertDialogHeader>
|
||||||
autoComplete="off"
|
<AlertDialogFooter>
|
||||||
disabled={isSubmitting}
|
<AlertDialogAction onClick={() => setErrorDialogOpen(false)}>Okay</AlertDialogAction>
|
||||||
readOnly
|
</AlertDialogFooter>
|
||||||
onFocus={(e) => e.target.removeAttribute('readonly')}
|
</AlertDialogContent>
|
||||||
{...register('email')}
|
</AlertDialog>
|
||||||
/>
|
</>
|
||||||
{errors.email && (
|
)
|
||||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{/* Password */}
|
|
||||||
<Field>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FieldLabel htmlFor="password">Password</FieldLabel>
|
|
||||||
<Link
|
|
||||||
to="/forgot-password"
|
|
||||||
className="ml-auto text-sm underline-offset-4 hover:underline"
|
|
||||||
>
|
|
||||||
Forgot Password?
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type={passwordVisible ? 'text' : 'password'}
|
|
||||||
placeholder="Enter your password"
|
|
||||||
autoComplete="off"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="pr-10"
|
|
||||||
readOnly
|
|
||||||
onFocus={(e) => e.target.removeAttribute('readonly')}
|
|
||||||
{...register('password')}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setPasswordVisible((prev) => !prev)}
|
|
||||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
{passwordVisible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{errors.password && (
|
|
||||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{/* Submit */}
|
|
||||||
<Field>
|
|
||||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
|
||||||
Logging in...
|
|
||||||
</span>
|
|
||||||
) : 'Login'}
|
|
||||||
</Button>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldDescription className="text-center font-medium">
|
|
||||||
Don't have an account?{' '}
|
|
||||||
<Link to="/signup" className="underline underline-offset-4">Sign up</Link>
|
|
||||||
</FieldDescription>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Error Dialog */}
|
|
||||||
<AlertDialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Login failed</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>{errorMessage}</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogAction onClick={() => setErrorDialogOpen(false)}>
|
|
||||||
Okay
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex items-center gap-0 mb-6">
|
||||||
|
{STEPS.map((s, i) => {
|
||||||
|
const done = i < current
|
||||||
|
const active = i === current
|
||||||
|
const isLast = i === STEPS.length - 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center flex-1 last:flex-none">
|
||||||
|
{/* Circle */}
|
||||||
|
<div className="flex flex-col items-center gap-1 shrink-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold border transition-colors',
|
||||||
|
done && 'bg-primary border-primary text-primary-foreground',
|
||||||
|
active && 'border-primary text-primary bg-background',
|
||||||
|
!done && !active && 'border-muted-foreground/30 text-muted-foreground/50 bg-background',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{done ? <Check className="size-3.5" strokeWidth={2.5} /> : i + 1}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] font-medium whitespace-nowrap',
|
||||||
|
active ? 'text-primary' : done ? 'text-foreground' : 'text-muted-foreground/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connector */}
|
||||||
|
{!isLast && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-px flex-1 mx-2 mb-4 transition-colors',
|
||||||
|
done ? 'bg-primary' : 'bg-muted-foreground/20',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<div className="flex gap-2 justify-center" onPaste={handlePaste}>
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Input
|
||||||
|
key={i}
|
||||||
|
ref={cellRefs[i]}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={1}
|
||||||
|
value={digits[i] || ''}
|
||||||
|
onChange={(e) => handleChange(i, e)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(i, e)}
|
||||||
|
className="w-11 h-12 text-center text-lg font-semibold p-0"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<>
|
||||||
|
<div className={cn('flex flex-col', className)} {...props}>
|
||||||
|
<StepIndicator current={step} />
|
||||||
|
|
||||||
|
{/* ── Step 1: Personal info ── */}
|
||||||
|
{step === 0 && (
|
||||||
|
<form onSubmit={submitPersonal(onPersonalNext)} className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tighter">Personal information.</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">Tell us a bit about yourself.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Given + Last name row */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="given_name" className="text-sm font-medium">
|
||||||
|
First name <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="given_name"
|
||||||
|
placeholder="Juan"
|
||||||
|
{...regPersonal('given_name')}
|
||||||
|
/>
|
||||||
|
{errPersonal.given_name && (
|
||||||
|
<p className="text-xs text-destructive">{errPersonal.given_name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="last_name" className="text-sm font-medium">
|
||||||
|
Last name <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="last_name"
|
||||||
|
placeholder="Dela Cruz"
|
||||||
|
{...regPersonal('last_name')}
|
||||||
|
/>
|
||||||
|
{errPersonal.last_name && (
|
||||||
|
<p className="text-xs text-destructive">{errPersonal.last_name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle name */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="middle_name" className="text-sm font-medium flex items-center gap-1.5">
|
||||||
|
Middle name
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="middle_name"
|
||||||
|
placeholder="Santos"
|
||||||
|
{...regPersonal('middle_name')}
|
||||||
|
/>
|
||||||
|
{errPersonal.middle_name && (
|
||||||
|
<p className="text-xs text-destructive">{errPersonal.middle_name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Extension name + Birthday row */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="extension_name" className="text-sm font-medium flex items-center gap-1.5">
|
||||||
|
Extension name
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="extension_name"
|
||||||
|
placeholder="Jr., Sr., III"
|
||||||
|
{...regPersonal('extension_name')}
|
||||||
|
/>
|
||||||
|
{errPersonal.extension_name && (
|
||||||
|
<p className="text-xs text-destructive">{errPersonal.extension_name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="date_of_birth" className="text-sm font-medium">
|
||||||
|
Birthday <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="date_of_birth"
|
||||||
|
type="date"
|
||||||
|
max={new Date().toISOString().split('T')[0]}
|
||||||
|
{...regPersonal('date_of_birth')}
|
||||||
|
/>
|
||||||
|
{errPersonal.date_of_birth && (
|
||||||
|
<p className="text-xs text-destructive">{errPersonal.date_of_birth.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Occupation */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="occupation" className="text-sm font-medium">
|
||||||
|
Occupation <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="occupation"
|
||||||
|
placeholder="e.g. Real Estate Broker"
|
||||||
|
{...regPersonal('occupation')}
|
||||||
|
/>
|
||||||
|
{errPersonal.occupation && (
|
||||||
|
<p className="text-xs text-destructive">{errPersonal.occupation.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="phone" className="text-sm font-medium flex items-center gap-1.5">
|
||||||
|
Phone number
|
||||||
|
<span className="text-xs font-normal text-muted-foreground">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
type="tel"
|
||||||
|
placeholder="+63 912 345 6789"
|
||||||
|
{...regPersonal('phone')}
|
||||||
|
/>
|
||||||
|
{errPersonal.phone && (
|
||||||
|
<p className="text-xs text-destructive">{errPersonal.phone.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full mt-1">
|
||||||
|
Next: Credentials →
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="font-medium underline underline-offset-4">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 2: Credentials ── */}
|
||||||
|
{step === 1 && (
|
||||||
|
<form onSubmit={submitCreds(onCredentialsSubmit)} className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tighter">Account credentials.</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">Set your login email and password.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group code notice */}
|
||||||
|
{groupCode && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-dashed px-3 py-2 bg-muted/40">
|
||||||
|
<Users className="size-4 text-muted-foreground shrink-0" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Joining group{' '}
|
||||||
|
<span className="font-semibold text-foreground">{groupCode}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="email" className="text-sm font-medium">
|
||||||
|
Email address <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={isRegistering}
|
||||||
|
readOnly
|
||||||
|
onFocus={(e) => e.target.removeAttribute('readonly')}
|
||||||
|
{...regCreds('email')}
|
||||||
|
/>
|
||||||
|
{errCreds.email && (
|
||||||
|
<p className="text-xs text-destructive">{errCreds.email.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="password" className="text-sm font-medium">
|
||||||
|
Password <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
placeholder="Min. 8 chars, 1 uppercase, 1 number"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isRegistering}
|
||||||
|
className="pr-10"
|
||||||
|
{...regCreds('password')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{errCreds.password && (
|
||||||
|
<p className="text-xs text-destructive">{errCreds.password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm password */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="confirm_password" className="text-sm font-medium">
|
||||||
|
Confirm password <span className="text-destructive">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="confirm_password"
|
||||||
|
type={showConfirm ? 'text' : 'password'}
|
||||||
|
placeholder="Repeat your password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={isRegistering}
|
||||||
|
className="pr-10"
|
||||||
|
{...regCreds('confirm_password')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowConfirm((v) => !v)}
|
||||||
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showConfirm ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{errCreds.confirm_password && (
|
||||||
|
<p className="text-xs text-destructive">{errCreds.confirm_password.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Group code — disabled, auto-filled from URL */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor="group_code" className="text-sm font-medium flex items-center gap-2">
|
||||||
|
Group code
|
||||||
|
<Badge variant="secondary" className="text-xs font-normal">Auto-filled</Badge>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="group_code"
|
||||||
|
placeholder="No group code"
|
||||||
|
disabled
|
||||||
|
{...regCreds('group_code')}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Provided by your group admin via invite link.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setStep(0)}
|
||||||
|
disabled={isRegistering}
|
||||||
|
>
|
||||||
|
← Back
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" className="flex-1" disabled={isRegistering}>
|
||||||
|
{isRegistering ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Create account →'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="font-medium underline underline-offset-4">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 3: OTP ── */}
|
||||||
|
{step === 2 && (
|
||||||
|
<form onSubmit={submitOtp(onVerifyOTP)} className="flex flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tighter">Check your email.</h1>
|
||||||
|
<p className="text-muted-foreground text-sm text-balance">
|
||||||
|
We sent a 6-digit code to{' '}
|
||||||
|
<span className="font-medium text-foreground">{pendingEmail}</span>.
|
||||||
|
It expires in 10 minutes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Controller
|
||||||
|
control={otpControl}
|
||||||
|
name="otp"
|
||||||
|
render={({ field }) => (
|
||||||
|
<OtpInput value={field.value} onChange={field.onChange} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{errOtp.otp && (
|
||||||
|
<p className="text-xs text-destructive text-center">{errOtp.otp.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isVerifying}>
|
||||||
|
{isVerifying ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<LoaderCircle className="size-4 animate-spin" />
|
||||||
|
Verifying...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Verify & sign in'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{resendCooldown > 0 ? `Resend in ${resendCooldown}s` : "Didn't receive it?"}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
className="p-0 h-auto font-medium"
|
||||||
|
disabled={resendCooldown > 0}
|
||||||
|
onClick={handleResend}
|
||||||
|
>
|
||||||
|
Resend code
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full text-muted-foreground"
|
||||||
|
onClick={() => { setStep(1); resetOtp() }}
|
||||||
|
>
|
||||||
|
← Back to credentials
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{step === 2 ? 'Verification failed' : 'Registration failed'}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{errorMessage}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogAction onClick={() => setErrorDialogOpen(false)}>Okay</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<MetadataProvider
|
||||||
|
value={{
|
||||||
|
title: 'Register - Philproperties',
|
||||||
|
description: 'Create an account to access our online course platform.',
|
||||||
|
keywords: 'register, signup, philproperties, training, onboarding, real-estate',
|
||||||
|
ogTitle: 'Register - Philproperties',
|
||||||
|
ogDescription: 'Create an account to access our online course platform.',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid min-h-svh lg:grid-cols-2">
|
||||||
|
{/* ── Left panel ── */}
|
||||||
|
<div className="flex flex-col gap-4 p-6 md:p-10">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Link to="/" className="flex items-center gap-2 font-medium">
|
||||||
|
<div className="xs:w-40 sm:w-48 md:w-56 2xl:w-64 block dark:hidden">
|
||||||
|
<img src="/philpro-white.png" alt="Philproperties" />
|
||||||
|
</div>
|
||||||
|
<div className="xs:w-40 sm:w-48 md:w-56 2xl:w-64 hidden dark:block">
|
||||||
|
<img src="/philpro-dark.png" alt="Philproperties" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<RegisterForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Right panel — hero image ── */}
|
||||||
|
<div className="relative hidden lg:block">
|
||||||
|
<img
|
||||||
|
src="https://cq5as7pc73.ufs.sh/f/pHNnzIw3VjcgzIbC8SR4stTZRKXP3cfbD6e2p9jmdAUQBuox"
|
||||||
|
alt="Philproperties"
|
||||||
|
className="absolute inset-0 h-full w-full object-cover rounded-3xl p-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MetadataProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import PublicRoute from '../../../routes/PublicRoute'
|
|||||||
import LandingLayout from '@/modules/public/layouts/LandingLayout'
|
import LandingLayout from '@/modules/public/layouts/LandingLayout'
|
||||||
import LandingPage from '@/modules/public/pages/LandingPage'
|
import LandingPage from '@/modules/public/pages/LandingPage'
|
||||||
import Login from '../pages/Login'
|
import Login from '../pages/Login'
|
||||||
|
import Register from '../pages/Register'
|
||||||
|
|
||||||
|
|
||||||
export const AuthRoutes = {
|
export const AuthRoutes = {
|
||||||
@@ -15,6 +16,7 @@ export const AuthRoutes = {
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <LandingLayout><LandingPage /></LandingLayout> },
|
{ index: true, element: <LandingLayout><LandingPage /></LandingLayout> },
|
||||||
{ path: "login", element: <Login />},
|
{ path: "login", element: <Login />},
|
||||||
|
{ path: "signup", element: <Register />}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import ProtectedRoute from '../../../routes/ProtectedRoute'
|
|||||||
import Client from '../pages/Client'
|
import Client from '../pages/Client'
|
||||||
|
|
||||||
export const ClientRoutes = {
|
export const ClientRoutes = {
|
||||||
element: <ProtectedRoute allowedRoles={['client']} />,
|
element: <ProtectedRoute allowedRoles={['user']} />,
|
||||||
children: [
|
children: [
|
||||||
{ path: '/client', element: <Client /> },
|
{ path: '/dashboard', element: <Client /> },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -9,10 +9,10 @@ export default function PublicRoute() {
|
|||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
switch (user.acc_type) {
|
switch (user.acc_type) {
|
||||||
case 'admin': return <Navigate to="/admin" replace />
|
case 'admin': return <Navigate to="/admin" replace />
|
||||||
case 'client': return <Navigate to="/client" replace />
|
case 'user': return <Navigate to="/dashboard" replace />
|
||||||
case 'staff': return <Navigate to="/staff" replace />
|
case 'staff': return <Navigate to="/staff" replace />
|
||||||
default: return <Navigate to="/admin" replace />
|
default: return <Navigate to="/dashboard" replace />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user