add: more things

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-06 15:21:36 +08:00
parent 41e98bb602
commit 244aa607f7
71 changed files with 2998 additions and 1014 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ import { AdminProvider } from "@/contexts/provider/AdminProvider" // ← add
import { TooltipProvider } from "@/components/ui/tooltip"
import { Badge } from "@/components/ui/badge"
import { Toaster } from "sonner"
import { Toaster } from "@/components/ui/sonner"
import { cn } from "@/lib/utils"
import UserMenu from "@/components/generic/UserMenu"
@@ -248,7 +248,12 @@ export default function CourseAssessment() {
setLocalAssessment(result);
} catch (err) {
if (err?.response?.status !== 404) {
toast.error(err?.response?.data?.message ?? "Could not load assessment.");
toast(err?.response?.data?.message ?? "Could not load assessment.", {
action: {
label: "Close",
onClick: () => {}
}
});
}
} finally {
setInitializing(false);
@@ -355,7 +355,12 @@ export default function ViewAssessment() {
setLocalAssessment(data?.data?.data ?? null);
} catch (err) {
if (err?.response?.status !== 404) {
toast.error(err?.response?.data?.message ?? "Could not load assessment.");
toast(err?.response?.data?.message ?? "Could not load assessment.", {
action: {
label: "Close",
onClick: () => {}
}
});
}
}
})();
@@ -240,7 +240,12 @@ export default function ModifyQuiz() {
setLocalQuiz(result);
} catch (err) {
if (err?.response?.status !== 404) {
toast.error(err?.response?.data?.message ?? "Could not load quiz.");
toast(err?.response?.data?.message ?? "Could not load quiz.", {
action: {
label: "Close",
onClick: () => {}
}
});
}
// 404 → no quiz yet, stay in create mode with localQuiz = null
} finally {
@@ -41,7 +41,12 @@ export default function NotificationSettings() {
const { data } = await api.get("/admin/notification-settings");
setSettings(data?.data ?? []);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Failed to load notification settings.");
toast(err?.response?.data?.message ?? "Failed to load notification settings.", {
action: {
label: "Close",
onClick: () => {}
}
});
} finally {
setLoading(false);
}
@@ -58,9 +63,22 @@ export default function NotificationSettings() {
});
const updated = data?.data?.data;
setSettings((prev) => prev.map((s) => (s.job_name === jobName ? { ...s, ...updated } : s)));
toast.success(`${JOB_LABELS[jobName]?.label ?? jobName} ${enabled ? "enabled" : "disabled"}.`);
toast(
`${JOB_LABELS[jobName]?.label ?? jobName} ${enabled ? "enabled" : "disabled"}.`,
{
action: {
label: "Close",
onClick: () => {}
}
}
);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Failed to update setting.");
toast(err?.response?.data?.message ?? "Failed to update setting.", {
action: {
label: "Close",
onClick: () => {}
}
});
} finally {
setSavingJob(null);
}
@@ -75,9 +93,19 @@ export default function NotificationSettings() {
});
const updated = data?.data?.data;
setSettings((prev) => prev.map((s) => (s.job_name === jobName ? { ...s, ...updated, preset } : s)));
toast.success("Schedule updated — took effect immediately, no restart needed.");
toast("Schedule updated — took effect immediately, no restart needed.", {
action: {
label: "Close",
onClick: () => {}
}
});
} catch (err) {
toast.error(err?.response?.data?.message ?? "Failed to update schedule.");
toast(err?.response?.data?.message ?? "Failed to update schedule.", {
action: {
label: "Close",
onClick: () => {}
}
});
} finally {
setSavingJob(null);
}
@@ -100,10 +100,20 @@ export default function PaymentPolicy() {
},
promo_rules: promoRules,
});
toast.success("Payment policy saved.");
toast("Payment policy saved.", {
action: {
label: "Close",
onClick: () => {}
}
});
navigate("/admin/tiers/plans");
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not save payment policy.");
toast(err?.response?.data?.message ?? "Could not save payment policy.", {
action: {
label: "Close",
onClick: () => {}
}
});
} finally {
setSaving(false);
}
@@ -113,10 +123,25 @@ export default function PaymentPolicy() {
const handleAddPromo = () => {
const code = addForm.code.trim().toUpperCase();
if (!code) { toast.error("Code is required."); return; }
if (!addForm.value || Number(addForm.value) <= 0) { toast.error("Value must be greater than 0."); return; }
if (!code) { toast("Code is required.", {
action: {
label: "Close",
onClick: () => {}
}
}); return; }
if (!addForm.value || Number(addForm.value) <= 0) { toast("Value must be greater than 0.", {
action: {
label: "Close",
onClick: () => {}
}
}); return; }
if (promoRules.some((r) => r.code.toUpperCase() === code)) {
toast.error("A rule with this code already exists."); return;
toast("A rule with this code already exists.", {
action: {
label: "Close",
onClick: () => {}
}
}); return;
}
const rule = {
+30 -5
View File
@@ -220,9 +220,19 @@ function PaymentPolicyTab({ planId, plan }) {
},
promo_rules: promoRules,
});
toast.success("Payment policy saved.");
toast("Payment policy saved.", {
action: {
label: "Close",
onClick: () => {}
}
});
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not save payment policy.");
toast(err?.response?.data?.message ?? "Could not save payment policy.", {
action: {
label: "Close",
onClick: () => {}
}
});
} finally {
setSaving(false);
}
@@ -230,9 +240,24 @@ function PaymentPolicyTab({ planId, plan }) {
const handleAddPromo = () => {
const code = addForm.code.trim().toUpperCase();
if (!code) { toast.error("Code is required."); return; }
if (!addForm.value || Number(addForm.value) <= 0) { toast.error("Value must be greater than 0."); return; }
if (promoRules.some((r) => r.code.toUpperCase() === code)) { toast.error("A rule with this code already exists."); return; }
if (!code) { toast("Code is required.", {
action: {
label: "Close",
onClick: () => {}
}
}); return; }
if (!addForm.value || Number(addForm.value) <= 0) { toast("Value must be greater than 0.", {
action: {
label: "Close",
onClick: () => {}
}
}); return; }
if (promoRules.some((r) => r.code.toUpperCase() === code)) { toast("A rule with this code already exists.", {
action: {
label: "Close",
onClick: () => {}
}
}); return; }
const rule = {
code,
+12 -2
View File
@@ -151,10 +151,20 @@ export default function EditUser() {
const res = await updateUser(id, payload);
if (res) {
toast.success("User updated successfully.");
toast("User updated successfully.", {
action: {
label: "Close",
onClick: () => {}
}
});
navigate(`../view/${id}`);
} else {
toast.error("Failed to update user.");
toast("Failed to update user.", {
action: {
label: "Close",
onClick: () => {}
}
});
}
};
@@ -0,0 +1,349 @@
/***********************************************************************************************************************************************************************
* File Name: ForgotPasswordForm.jsx
* Type of Program: Frontend Component
* Description: Password reset flow — same procedure for every acc_type (admin,
* staff, user); only reg_type matters (Google accounts are turned
* away, see LoginForm's "Please log in with Google" precedent).
* Step 1 — Email (checks reg_type server-side, sends OTP if system)
* Step 2 — OTP + new password + confirm, single submit
* Module: User Credentials
* Author: lash0000
* Date Created: Jul. 4, 2026
***********************************************************************************************************************************************************************/
import { useState, useEffect } from 'react'
import { useNavigate, Link } 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 { Separator } from '@/components/ui/separator'
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Eye, EyeOff, LoaderCircle } from 'lucide-react'
const emailSchema = z.object({
email: z.string().email('Invalid email address'),
})
const resetSchema = z
.object({
otp: z.string().length(6, 'Enter all 6 digits').regex(/^\d{6}$/, 'OTP must contain only digits'),
new_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'),
})
.refine((d) => d.new_password === d.confirm_password, {
message: 'Passwords do not match',
path: ['confirm_password'],
})
export function ForgotPasswordForm({ className, ...props }) {
const navigate = useNavigate()
const { forgotPassword, resetPassword } = useAuth()
// 0 = email, 1 = otp + new password
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('')
const [successDialogOpen, setSuccessDialogOpen] = useState(false)
useEffect(() => {
if (resendCooldown <= 0) return
const t = setTimeout(() => setResendCooldown((c) => c - 1), 1000)
return () => clearTimeout(t)
}, [resendCooldown])
// ── Step 1: Email ──────────────────────────────────────────────────────────
const {
register: regEmail,
handleSubmit: submitEmail,
formState: { errors: errEmail, isSubmitting: isRequesting },
} = useForm({
resolver: zodResolver(emailSchema),
defaultValues: { email: '' },
})
const onEmailSubmit = async ({ email }) => {
const result = await forgotPassword({ email })
if (!result.success) {
setErrorMessage(result.message)
setErrorDialogOpen(true)
return
}
setPendingEmail(email)
setResendCooldown(30)
setStep(1)
}
// ── Step 2: OTP + new password ────────────────────────────────────────────
const {
control: resetControl,
register: regReset,
handleSubmit: submitReset,
formState: { errors: errReset, isSubmitting: isResetting },
} = useForm({
resolver: zodResolver(resetSchema),
defaultValues: { otp: '', new_password: '', confirm_password: '' },
})
const onResetSubmit = async ({ otp, new_password }) => {
const result = await resetPassword({ email: pendingEmail, otp, new_password })
if (!result.success) {
setErrorMessage(result.message)
setErrorDialogOpen(true)
return
}
setSuccessDialogOpen(true)
}
const handleResend = async () => {
if (resendCooldown > 0) return
const result = await forgotPassword({ email: pendingEmail })
if (!result.success) {
setErrorMessage(result.message)
setErrorDialogOpen(true)
return
}
setResendCooldown(30)
}
return (
<>
<div className={cn('flex flex-col', className)} {...props}>
{/* ── Step 1: Email ── */}
{step === 0 && (
<form onSubmit={submitEmail(onEmailSubmit)} className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tighter">Forgot your password?</h1>
<p className="text-muted-foreground text-sm text-balance">
Enter your email and we'll send you a code to reset it.
</p>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="email" className="text-sm font-medium">Email address</label>
<Input
id="email"
type="email"
placeholder="you@example.com"
autoComplete="off"
disabled={isRequesting}
readOnly
onFocus={(e) => e.target.removeAttribute('readonly')}
{...regEmail('email')}
/>
{errEmail.email && (
<p className="text-xs text-destructive">{errEmail.email.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isRequesting}>
{isRequesting ? (
<span className="flex items-center gap-2">
<LoaderCircle className="size-4 animate-spin" />
Sending...
</span>
) : (
'Send reset code'
)}
</Button>
<p className="text-center text-sm text-muted-foreground">
Remembered it?{' '}
<Link to="/login" className="font-medium underline underline-offset-4">
Back to login
</Link>
</p>
</form>
)}
{/* ── Step 2: OTP + new password ── */}
{step === 1 && (
<form onSubmit={submitReset(onResetSubmit)} className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tighter">Reset your password.</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>.
Enter it below along with your new password.
</p>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-center">Verification code</label>
<Controller
control={resetControl}
name="otp"
render={({ field }) => (
<InputOTP
maxLength={6}
value={field.value}
onChange={field.onChange}
containerClassName="justify-center"
>
<InputOTPGroup>
{Array.from({ length: 6 }).map((_, i) => (
<InputOTPSlot key={i} index={i} />
))}
</InputOTPGroup>
</InputOTP>
)}
/>
{errReset.otp && (
<p className="text-xs text-destructive text-center">{errReset.otp.message}</p>
)}
</div>
<div className="flex items-center justify-between text-sm -mt-2">
<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>
{/* New password */}
<div className="flex flex-col gap-1.5">
<label htmlFor="new_password" className="text-sm font-medium">New password</label>
<div className="relative">
<Input
id="new_password"
type={showPassword ? 'text' : 'password'}
placeholder="Min. 8 chars, 1 uppercase, 1 number"
autoComplete="new-password"
disabled={isResetting}
className="pr-10"
{...regReset('new_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>
{errReset.new_password && (
<p className="text-xs text-destructive">{errReset.new_password.message}</p>
)}
</div>
{/* Confirm password */}
<div className="flex flex-col gap-1.5">
<label htmlFor="confirm_password" className="text-sm font-medium">Confirm new password</label>
<div className="relative">
<Input
id="confirm_password"
type={showConfirm ? 'text' : 'password'}
placeholder="Repeat your new password"
autoComplete="new-password"
disabled={isResetting}
className="pr-10"
{...regReset('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>
{errReset.confirm_password && (
<p className="text-xs text-destructive">{errReset.confirm_password.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isResetting}>
{isResetting ? (
<span className="flex items-center gap-2">
<LoaderCircle className="size-4 animate-spin" />
Resetting...
</span>
) : (
'Submit'
)}
</Button>
<Separator />
<Button
type="button"
variant="ghost"
className="w-full text-muted-foreground"
onClick={() => setStep(0)}
>
← Back
</Button>
</form>
)}
</div>
{/* Error Dialog */}
<AlertDialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Something went wrong</AlertDialogTitle>
<AlertDialogDescription>{errorMessage}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setErrorDialogOpen(false)}>Okay</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Success Dialog */}
<AlertDialog open={successDialogOpen} onOpenChange={setSuccessDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Password changed</AlertDialogTitle>
<AlertDialogDescription>
Your password has been reset successfully. Please log in with your new password.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => navigate('/login')}>Go to login</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
+25 -5
View File
@@ -19,6 +19,8 @@ import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { cn } from '@/lib/utils'
import { getRoleHomePath } from '@/utils/roleRedirect.util'
import { OtpVerifyForm } from './OtpVerifyForm'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -48,6 +50,7 @@ export function LoginForm({ className, ...props }) {
const [passwordVisible, setPasswordVisible] = useState(false)
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [otpEmail, setOtpEmail] = useState(null)
const {
register,
@@ -58,16 +61,21 @@ export function LoginForm({ className, ...props }) {
defaultValues: { email: '', password: '' },
})
const handleAuthSuccess = (user) => {
navigate(getRoleHomePath(user))
}
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')
if (result.otpRequired === false) {
// Trusted device — session was issued directly, no OTP step needed.
handleAuthSuccess(result.user)
return
}
// Credentials confirmed — an OTP was emailed. Tokens aren't issued yet.
setOtpEmail(result.email)
return
}
@@ -90,6 +98,18 @@ export function LoginForm({ className, ...props }) {
window.location.href = '/api/auth/google'
}
if (otpEmail) {
return (
<OtpVerifyForm
email={otpEmail}
onSuccess={handleAuthSuccess}
title="Verify your sign-in"
description={`We sent a 6-digit code to ${otpEmail} to finish signing in. It expires in 10 minutes.`}
onBack={() => setOtpEmail(null)}
/>
)
}
return (
<>
<form
@@ -0,0 +1,186 @@
/***********************************************************************************************************************************************************************
* File Name: OtpVerifyForm.jsx
* Type of Program: Frontend Component
* Description: Shared OTP step used by every auth path — registration email
* verification, post-password login, and post-Google login.
* All three funnel through POST /auth/verify-otp, which mints
* tokens/session on success regardless of which path led here.
* Module: User Credentials
* Author: lash0000
* Date Created: Jul. 4, 2026
***********************************************************************************************************************************************************************/
import { useState, useEffect } from 'react'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useAuth } from '@/contexts/AuthContext'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { LoaderCircle } from 'lucide-react'
const otpSchema = z.object({
otp: z
.string()
.length(6, 'Enter all 6 digits')
.regex(/^\d{6}$/, 'OTP must contain only digits'),
})
/**
* @param {string} email — the account this OTP was sent to
* @param {function} onSuccess — called with the authenticated user on success
* @param {string} [title]
* @param {string|JSX.Element} [description]
* @param {function} [onBack] — if provided, renders a "back" action (e.g. registration's "back to credentials")
*/
export function OtpVerifyForm({ email, onSuccess, title = 'Check your email.', description, onBack }) {
const { verifyOTP, resendOTP } = useAuth()
const [resendCooldown, setResendCooldown] = useState(30)
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
useEffect(() => {
if (resendCooldown <= 0) return
const t = setTimeout(() => setResendCooldown((c) => c - 1), 1000)
return () => clearTimeout(t)
}, [resendCooldown])
const {
control,
handleSubmit,
reset,
formState: { errors, isSubmitting: isVerifying },
} = useForm({
resolver: zodResolver(otpSchema),
defaultValues: { otp: '' },
})
const onVerify = async ({ otp }) => {
const result = await verifyOTP({ email, otp })
if (!result.success) {
setErrorMessage(result.message)
setErrorDialogOpen(true)
return
}
onSuccess(result.user)
}
const handleResend = async () => {
if (resendCooldown > 0) return
const result = await resendOTP({ email })
if (!result.success) {
setErrorMessage(result.message)
setErrorDialogOpen(true)
return
}
setResendCooldown(30)
reset()
}
return (
<>
<form onSubmit={handleSubmit(onVerify)} className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tighter">{title}</h1>
<p className="text-muted-foreground text-sm text-balance">
{description ?? (
<>
We sent a 6-digit code to{' '}
<span className="font-medium text-foreground">{email}</span>.
It expires in 10 minutes.
</>
)}
</p>
</div>
<div className="flex flex-col gap-1.5">
<Controller
control={control}
name="otp"
render={({ field }) => (
<InputOTP
maxLength={6}
value={field.value}
onChange={field.onChange}
containerClassName="justify-center"
>
<InputOTPGroup>
{Array.from({ length: 6 }).map((_, i) => (
<InputOTPSlot key={i} index={i} />
))}
</InputOTPGroup>
</InputOTP>
)}
/>
{errors.otp && (
<p className="text-xs text-destructive text-center">{errors.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'
)}
</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>
{onBack && (
<>
<Separator />
<Button type="button" variant="ghost" className="w-full text-muted-foreground" onClick={onBack}>
← Back
</Button>
</>
)}
</form>
<AlertDialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Verification failed</AlertDialogTitle>
<AlertDialogDescription>{errorMessage}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setErrorDialogOpen(false)}>Okay</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
+10 -170
View File
@@ -15,18 +15,18 @@
* 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 { useState } from 'react'
import { useNavigate, Link, useSearchParams } from 'react-router-dom'
import { useForm, Controller } from 'react-hook-form'
import { useForm } 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 { OtpVerifyForm } from './OtpVerifyForm'
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,
@@ -72,13 +72,6 @@ const credentialsSchema = z
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' },
@@ -134,58 +127,10 @@ function StepIndicator({ current }) {
)
}
// ─── 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 { register: authRegister } = useAuth()
const [searchParams] = useSearchParams()
const groupCode = searchParams.get('group_code') || ''
@@ -194,20 +139,12 @@ export function RegisterForm({ className, ...props }) {
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,
@@ -278,48 +215,9 @@ export function RegisterForm({ className, ...props }) {
}
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', { state: { justRegistered: true } })
}
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 (
<>
@@ -608,76 +506,18 @@ export function RegisterForm({ className, ...props }) {
{/* ── 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>
<OtpVerifyForm
email={pendingEmail}
onSuccess={() => navigate('/dashboard', { state: { justRegistered: true } })}
onBack={() => setStep(1)}
/>
)}
</div>
<AlertDialog open={errorDialogOpen} onOpenChange={setErrorDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{step === 2 ? 'Verification failed' : 'Registration failed'}
</AlertDialogTitle>
<AlertDialogTitle>Registration failed</AlertDialogTitle>
<AlertDialogDescription>{errorMessage}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
+12 -2
View File
@@ -94,7 +94,12 @@ export default function ChangePassword() {
// Update local user state so must_change_password is cleared
setUser((prev) => ({ ...prev, must_change_password: false }));
toast.success('Password changed successfully. Welcome!');
toast('Password changed successfully. Welcome!', {
action: {
label: "Close",
onClick: () => {}
}
});
// Redirect to the correct dashboard
switch (user?.acc_type) {
@@ -104,7 +109,12 @@ export default function ChangePassword() {
default: navigate('/');
}
} catch (err) {
toast.error(err?.response?.data?.message || 'Could not change password.');
toast(err?.response?.data?.message || 'Could not change password.', {
action: {
label: "Close",
onClick: () => {}
}
});
}
};
+54
View File
@@ -0,0 +1,54 @@
/***********************************************************************************************************************************************************************
* File Name: ForgotPassword.jsx
* Type of Program: Frontend Page
* Description: Forgot-password page. Route: /forgot-password
* Module: User Credentials
* Author: lash0000
* Date Created: Jul. 4, 2026
***********************************************************************************************************************************************************************/
import { Link } from 'react-router-dom'
import { ForgotPasswordForm } from '../components/ForgotPasswordForm'
import { MetadataProvider } from '@/contexts/MetadataContext'
export default function ForgotPassword() {
return (
<MetadataProvider
value={{
title: 'Forgot Password - Philproperties',
description: 'Reset your account password.',
keywords: 'forgot password, reset password, philproperties',
ogTitle: 'Forgot Password - Philproperties',
ogDescription: 'Reset your account password.',
}}
>
<div className="grid min-h-svh lg:grid-cols-2">
<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">
<ForgotPasswordForm />
</div>
</div>
</div>
<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>
)
}
+10 -2
View File
@@ -17,7 +17,7 @@ import { Label } from '@/components/ui/label'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { Toaster } from 'sonner'
import { Toaster } from '@/components/ui/sonner'
import api from '@/utils/api.util'
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -120,7 +120,15 @@ export default function IntroPage() {
setUser(prev => ({ ...prev, ...data.data }))
navigate('/dashboard')
} catch (err) {
toast.error(err?.response?.data?.message ?? 'Could not save your info. Please try again.')
toast(
err?.response?.data?.message ?? 'Could not save your info. Please try again.',
{
action: {
label: "Close",
onClick: () => {}
}
}
)
} finally {
setLoading(false)
}
+61 -6
View File
@@ -3,24 +3,40 @@
* Type of Program: Frontend Page
* Description: Landing page after the backend completes Google OIDC.
*
* Happy path → backend set the refreshToken cookie and redirected here.
* App.jsx's restoreSession() fires on mount, picks up the cookie,
* and calls /auth/refresh → sets user. PublicRoute then redirects
* to the appropriate dashboard. This page shows a loading spinner
* for the brief moment before that redirect fires.
* OTP path → backend confirmed the Google identity but, like every other
* login path, still gates on an OTP before issuing tokens. It
* redirects here with ?otpRequired=true&email=<email> and has
* NOT set a refresh cookie yet. This page renders the shared
* OtpVerifyForm; once verified, AuthContext has user/tokens
* set and we navigate to the account's home route ourselves.
*
* Trusted path → this device already cleared an OTP recently and its trust
* window is still valid. Backend redirects with
* ?otpRequired=false, having already set the refresh cookie —
* this page calls restoreSession() to pull the access token
* from it, then navigates to the account's home route.
*
* Error path → backend could not complete OIDC (state mismatch, token exchange
* failure, deactivated account, etc.). It redirected here with
* ?error=<code>. No refresh cookie was set, so restoreSession()
* will fail and the user stays on this page to see the error.
*
* Fallback → neither param present (shouldn't normally happen now that the
* backend always redirects with one or the other) — falls back
* to the old spinner + restoreSession()/PublicRoute behavior.
*
* Author: Kenneth Obsequio (@lash0000)
* Date Created: Jun. 21, 2026
* Date Modified: Jul. 5, 2026 — trusted-device OTP skip path
***********************************************************************************************************************************************************************/
import { useSearchParams, Link } from 'react-router-dom'
import { useEffect } from 'react'
import { useSearchParams, Link, useNavigate } from 'react-router-dom'
import { LoaderCircle, ShieldBan, UserX, AlertTriangle, RefreshCw, Clock } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/contexts/AuthContext'
import { useDateFormat } from '@/hooks/useDateFormat'
import { getRoleHomePath } from '@/utils/roleRedirect.util'
import { OtpVerifyForm } from '@/modules/auth/components/OtpVerifyForm'
const ERROR_MAP = {
access_denied: { icon: UserX, message: 'You cancelled the Google sign-in.' },
@@ -32,8 +48,47 @@ const ERROR_MAP = {
export default function OAuthCallback() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const { restoreSession } = useAuth()
const { fmtDateTime } = useDateFormat()
const error = searchParams.get('error')
const otpRequiredParam = searchParams.get('otpRequired')
const otpRequired = otpRequiredParam === 'true'
const trusted = otpRequiredParam === 'false'
const email = searchParams.get('email')
useEffect(() => {
if (!trusted) return
restoreSession().then(({ success, user }) => {
navigate(success ? getRoleHomePath(user) : '/login', { replace: true })
})
}, [trusted])
if (trusted) {
return (
<div className="flex min-h-svh items-center justify-center">
<div className="flex flex-col items-center gap-3">
<LoaderCircle className="size-6 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">Signing you in...</p>
</div>
</div>
)
}
if (otpRequired && email) {
return (
<div className="flex min-h-svh items-center justify-center p-6">
<div className="w-full max-w-sm">
<OtpVerifyForm
email={email}
onSuccess={(user) => navigate(getRoleHomePath(user), { replace: true })}
title="Verify your sign-in"
description={`We sent a 6-digit code to ${email} to finish signing in with Google. It expires in 10 minutes.`}
/>
</div>
</div>
)
}
if (error === 'account_banned') {
const reason = searchParams.get('reason')
+2
View File
@@ -5,6 +5,7 @@ import LandingLayout from '@/modules/public/layouts/LandingLayout'
import LandingPage from '@/modules/public/pages/LandingPage'
import Login from '../pages/Login'
import Register from '../pages/Register'
import ForgotPassword from '../pages/ForgotPassword'
import OAuthCallback from '../pages/OAuthCallback'
import Suspended from '@/modules/public/pages/Suspended'
@@ -19,6 +20,7 @@ export const AuthRoutes = {
{ index: true, element: <LandingLayout><LandingPage /></LandingLayout> },
{ path: "login", element: <Login />},
{ path: "signup", element: <Register />},
{ path: "forgot-password", element: <ForgotPassword /> },
{ path: "auth/callback/google", element: <OAuthCallback /> },
{ path: "suspended", element: <Suspended /> },
]
@@ -178,9 +178,15 @@ const FileUpload = ({
return ok;
});
if (rejected.length > 0) {
setTimeout(() => toast.error(
setTimeout(() => toast(
`${rejected.length === 1 ? `"${rejected[0]}" is` : `${rejected.length} files are`} not allowed. ` +
`Accepted types: ${allowed.join(", ")}.`
`Accepted types: ${allowed.join(", ")}.`,
{
action: {
label: "Close",
onClick: () => {}
}
}
), 0);
}
}
@@ -190,14 +196,26 @@ const FileUpload = ({
if (maxFileCount) {
const availableSlots = maxFileCount - prev.length;
if (availableSlots <= 0) {
setTimeout(() => toast.error(
`You can only attach up to ${maxFileCount} file${maxFileCount !== 1 ? "s" : ""}.`
setTimeout(() => toast(
`You can only attach up to ${maxFileCount} file${maxFileCount !== 1 ? "s" : ""}.`,
{
action: {
label: "Close",
onClick: () => {}
}
}
), 0);
incoming = [];
} else if (incoming.length > availableSlots) {
setTimeout(() => toast.error(
setTimeout(() => toast(
`Only ${availableSlots} more file${availableSlots !== 1 ? "s" : ""} can be added ` +
`(max ${maxFileCount}).`
`(max ${maxFileCount}).`,
{
action: {
label: "Close",
onClick: () => {}
}
}
), 0);
incoming = incoming.slice(0, availableSlots);
}
@@ -222,11 +240,14 @@ const FileUpload = ({
}
});
if (duplicates.length > 0) {
setTimeout(() => toast.error(
duplicates.length === 1
? `"${duplicates[0]}" is already attached.`
: `${duplicates.length} files are already attached.`
), 0);
setTimeout(() => toast(duplicates.length === 1
? `"${duplicates[0]}" is already attached.`
: `${duplicates.length} files are already attached.`, {
action: {
label: "Close",
onClick: () => {}
}
}), 0);
}
const next = prev.concat(toAdd);
toAdd.forEach((e) => simulateUpload(e.id));
@@ -40,7 +40,12 @@ const ReadCourse = ({ title = "Read Course", courses = [], groupId, taskListId,
courses.forEach((course) => {
const prev = prevCompletedRef.current[course.id];
if (course.completed && prev === false) {
toast.success(`"${course.title}" has been automatically turned in!`);
toast(`"${course.title}" has been automatically turned in!`, {
action: {
label: "Close",
onClick: () => {}
}
});
}
prevCompletedRef.current[course.id] = !!course.completed;
});
@@ -1,4 +1,4 @@
import { ExternalLink, CheckCheck, RefreshCcw } from "lucide-react";
import { ExternalLink, CheckCheck, RefreshCcw, Globe } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Card,
@@ -9,7 +9,7 @@ import {
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { useEffect, useState } from "react";
import { useState } from "react";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { SendHorizonal } from "lucide-react";
@@ -20,47 +20,41 @@ const normalizeUrl = (url) => {
return `https://${url}`;
};
// ── Meta fetcher ──────────────────────────────────────────────────────────────
const fetchLinkMeta = async (url) => {
const normalized = normalizeUrl(url);
try {
const res = await fetch(`https://api.microlink.io/?url=${encodeURIComponent(normalized)}`);
const json = await res.json();
if (json.status === "success") {
return {
title: json.data.title ?? null,
description: json.data.description ?? null,
image: json.data.image?.url ?? json.data.logo?.url ?? null,
};
}
} catch { /* silently fail */ }
return { title: null, description: null, image: null };
};
const getDomain = (url) => {
try { return new URL(normalizeUrl(url)).hostname.replace(/^www\./, ''); }
catch { return url; }
};
// ── Fallback banner — favicon over a gradient, no external preview fetch ──────
const LinkImageFallback = ({ domain, favicon, className = "h-40" }) => {
const [faviconFailed, setFaviconFailed] = useState(false);
return (
<div className={`w-full ${className} bg-gradient-to-br from-muted via-muted to-primary/10 flex flex-col items-center justify-center gap-2`}>
{!faviconFailed && favicon ? (
<img
src={favicon}
alt={domain}
className="w-12 h-12 rounded-xl"
onError={() => setFaviconFailed(true)}
/>
) : (
<Globe className="size-10 text-muted-foreground/50" />
)}
<span className="text-xs text-muted-foreground font-medium">{domain}</span>
</div>
);
};
// ── LinkCard ──────────────────────────────────────────────────────────────────
const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting }) => {
const [meta, setMeta] = useState({ title: null, description: null, image: null });
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [viewModalOpen, setViewModalOpen] = useState(false);
useEffect(() => {
if (!link.url) return;
fetchLinkMeta(link.url)
.then((data) => setMeta(data))
.finally(() => setLoading(false));
}, [link.url]);
const domain = getDomain(link.url);
const displayImage = meta.image ?? null;
const displayFavicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`;
const displayTitle = meta.title ?? link.label ?? domain;
const displayDescription = meta.description ?? link.url;
const domain = getDomain(link.url);
const displayFavicon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128`;
const displayTitle = link.label ?? domain;
const displayDescription = link.url;
const handleTurnIn = async () => {
await onTurnIn(link.requirement_id);
@@ -75,39 +69,10 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
return (
<>
<Card className="relative w-72 shrink-0 pt-0">
{loading ? (
<div className="h-40 w-full rounded-t-lg bg-muted animate-pulse" />
) : displayImage ? (
<img
src={displayImage}
alt={displayTitle}
className="h-40 w-full object-cover rounded-t-lg"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
) : (
<div className="h-40 w-full rounded-t-lg bg-muted flex flex-col items-center justify-center gap-2">
<img
src={displayFavicon}
alt={domain}
className="w-12 h-12 rounded-xl"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<span className="text-xs text-muted-foreground font-medium">{domain}</span>
</div>
)}
<LinkImageFallback domain={domain} favicon={displayFavicon} className="h-40 rounded-t-lg" />
<CardHeader>
<CardTitle className="line-clamp-1">
{loading
? <span className="block h-4 w-32 bg-muted animate-pulse rounded" />
: displayTitle
}
</CardTitle>
<CardDescription className="truncate text-xs">
{loading
? <span className="block h-3 w-48 bg-muted animate-pulse rounded" />
: displayDescription
}
</CardDescription>
<CardTitle className="line-clamp-1">{displayTitle}</CardTitle>
<CardDescription className="truncate text-xs">{displayDescription}</CardDescription>
</CardHeader>
<CardFooter>
{visited ? (
@@ -137,11 +102,10 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
</>
}
>
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground break-all">{link.url}</p>
<Button asChild variant="outline" className="w-full">
<div className="flex flex-col gap-3 w-fit">
<Button asChild variant="link" className="text-blue-500">
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-4" /> Open Link
<ExternalLink /> {link.url}
</a>
</Button>
</div>
@@ -156,11 +120,6 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
footer={
<>
<Button variant="outline" onClick={() => setViewModalOpen(false)}>Close</Button>
<Button asChild variant="outline">
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
<ExternalLink className="size-4" /> Open Link
</a>
</Button>
<Button onClick={handleUnsubmit} disabled={unsubmitting} variant="destructive">
<RefreshCcw className="size-4" /> {unsubmitting ? "Removing…" : "Unsubmit"}
</Button>
@@ -168,23 +127,18 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
}
>
<div className="flex flex-col gap-4">
{displayImage ? (
<img
src={displayImage}
alt={displayTitle}
className="w-full h-40 object-cover rounded-lg"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
) : (
<div className="w-full h-40 rounded-lg bg-muted flex flex-col items-center justify-center gap-2">
<img src={displayFavicon} alt={domain} className="w-12 h-12 rounded-xl" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
<span className="text-xs text-muted-foreground font-medium">{domain}</span>
</div>
)}
<LinkImageFallback domain={domain} favicon={displayFavicon} className="h-40 rounded-lg" />
<div className="flex items-center gap-2 bg-green-500/10 text-green-600 dark:text-green-400 rounded-lg px-4 py-3 text-sm font-medium">
<CheckCheck className="size-4 shrink-0" /> Already submitted — you can unsubmit if needed.
</div>
<p className="text-xs text-muted-foreground break-all">{link.url}</p>
<div>
<Button asChild variant="link" className="text-blue-500">
<a href={normalizeUrl(link.url)} target="_blank" rel="noopener noreferrer">
<ExternalLink /> {link.url}
</a>
</Button>
</div>
</div>
</ResponsiveModal>
</>
@@ -201,7 +155,7 @@ const LinkCard = ({ link, visited, onTurnIn, onUnvisit, submitting, unsubmitting
* called when user confirms "Turn In"
*/
const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit, onUnvisit }) => {
const [submittingId, setSubmittingId] = useState(null);
const [submittingId, setSubmittingId] = useState(null);
const [unsubmittingId, setUnsubmittingId] = useState(null);
const handleTurnIn = async (requirementId) => {
+18 -11
View File
@@ -1,5 +1,6 @@
import { Outlet, useMatches, useNavigate } from "react-router-dom"
import { ThemeSwitcher } from "../components/ThemeSwitcher"
import { useTheme } from "@/contexts/ThemeContext"
import {
DropdownMenu,
DropdownMenuContent,
@@ -22,7 +23,7 @@ import {
import * as LucideIcons from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Toaster } from "sonner"
import { Toaster } from "@/components/ui/sonner"
import { useAuth } from "@/contexts/AuthContext"
import api from "@/utils/api.util"
import { ClientProvider } from "@/contexts/provider/ClientProvider"
@@ -141,6 +142,7 @@ function getInitials(name = "") {
function ClientNav() {
const navigate = useNavigate()
const { user, logout } = useAuth()
const { setTheme } = useTheme()
// Background fetches only — nav rendering never waits on these
const { achievements, getAchievements } = useProfile()
@@ -212,6 +214,7 @@ function ClientNav() {
const handleLogout = async () => {
await logout()
setTheme('light')
navigate("/login")
}
@@ -295,7 +298,7 @@ function ClientNav() {
<DropdownMenuItem onClick={() => navigate("/settings")}>
<Settings /> Account Settings
</DropdownMenuItem>
<DropdownMenuItem>
{/* <DropdownMenuItem>
<TableOfContents /> Documentation
<DropdownMenuShortcut>
<SquareArrowOutUpRight />
@@ -306,7 +309,7 @@ function ClientNav() {
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setReferOpen(true)}>
<Gift /> Refer
</DropdownMenuItem>
</DropdownMenuItem> */}
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Theme
<DropdownMenuShortcut>
@@ -343,14 +346,18 @@ const ClientLayout = () => {
return (
<ClientProvider>
<ClientNav />
<Outlet />
<Toaster position="bottom-right" richColors />
{showFooter && (
<footer className="w-full py-4 px-5 text-right text-sm text-muted-foreground">
© Philproperties, 2026
</footer>
)}
<div className="min-h-screen flex flex-col">
<ClientNav />
<div className="flex-1 flex flex-col">
<Outlet />
</div>
<Toaster position="bottom-right" richColors />
{showFooter && (
<footer className="bg-muted border-t w-full py-4 px-5 text-right text-sm text-muted-foreground">
© Philproperties, 2026
</footer>
)}
</div>
</ClientProvider>
)
}
+123 -186
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { KeyRound, CreditCard, Mail, Megaphone, Info, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2 } from "lucide-react";
import { KeyRound, CreditCard, Megaphone, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -63,11 +63,21 @@ function SecuritySection({ user, logout }) {
const handleSubmit = async (e) => {
e.preventDefault();
if (form.new_password !== form.confirm) {
toast.error("New passwords do not match.");
toast("New passwords do not match.", {
action: {
label: "Close",
onClick: () => {}
}
});
return;
}
if (form.new_password.length < 8) {
toast.error("New password must be at least 8 characters.");
toast("New password must be at least 8 characters.", {
action: {
label: "Close",
onClick: () => {}
}
});
return;
}
setLoading(true);
@@ -76,13 +86,23 @@ function SecuritySection({ user, logout }) {
current_password: form.current_password,
new_password: form.new_password,
});
toast.success("Password changed. Logging you out…");
toast("Password changed. Logging you out…", {
action: {
label: "Close",
onClick: () => {}
}
});
setTimeout(async () => {
await logout();
navigate("/login");
}, 1500);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not change password.");
toast(err?.response?.data?.message ?? "Could not change password.", {
action: {
label: "Close",
onClick: () => {}
}
});
} finally {
setLoading(false);
}
@@ -229,111 +249,6 @@ function SubscriptionSection() {
);
}
// ─── Newsletter ───────────────────────────────────────────────────────────────
const NEWSLETTER_OPTIONS = [
{
key: "newsletter_course_updates",
label: "Course updates",
description: "Emails about new courses, lesson releases, and learning milestones.",
},
{
key: "newsletter_announcements",
label: "Announcements",
description: "Platform news, promotions, and important updates from Philproperties.",
},
];
function NewsletterSection() {
const { profile, getProfile, updateProfile, profileLoading } = useProfile();
useEffect(() => {
getProfile();
}, []);
const handleToggle = async (key, value) => {
const result = await updateProfile({ [key]: value });
if (result?.success) {
toast.success(value ? "Preference saved." : "Preference saved.");
}
};
return (
<div className="space-y-4">
{NEWSLETTER_OPTIONS.map((opt, i) => (
<div key={opt.key}>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">{opt.label}</p>
<p className="text-xs text-muted-foreground">{opt.description}</p>
</div>
{profileLoading ? (
<Skeleton className="h-6 w-11 rounded-full shrink-0" />
) : (
<Switch
checked={profile?.personal_info?.[opt.key] ?? false}
onCheckedChange={(v) => handleToggle(opt.key, v)}
/>
)}
</div>
{i < NEWSLETTER_OPTIONS.length - 1 && <Separator className="mt-4" />}
</div>
))}
</div>
);
}
// ─── Course Notices ───────────────────────────────────────────────────────────
// One-time informational dialogs shown while studying (e.g. InfoDialog in
// UnitList.jsx) — this list grows as more generic client notices are added.
const NOTICE_OPTIONS = [
{
key: "show_course_notices",
label: "Course notices",
description: "Informational pop-ups about course readiness, such as when an assessment hasn't been built yet.",
},
];
function CourseNoticesSection() {
const { profile, getProfile, updateProfile, profileLoading } = useProfile();
useEffect(() => {
getProfile();
}, []);
const handleToggle = async (key, value) => {
const result = await updateProfile({ [key]: value });
if (result?.success) {
toast.success("Preference saved.");
}
};
return (
<div className="space-y-4">
{NOTICE_OPTIONS.map((opt, i) => (
<div key={opt.key}>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">{opt.label}</p>
<p className="text-xs text-muted-foreground">{opt.description}</p>
</div>
{profileLoading ? (
<Skeleton className="h-6 w-11 rounded-full shrink-0" />
) : (
<Switch
checked={profile?.personal_info?.[opt.key] ?? true}
onCheckedChange={(v) => handleToggle(opt.key, v)}
/>
)}
</div>
{i < NOTICE_OPTIONS.length - 1 && <Separator className="mt-4" />}
</div>
))}
</div>
);
}
// ─── Advertisements ───────────────────────────────────────────────────────────
const AD_OPTIONS = [
@@ -372,7 +287,13 @@ function AdvertisementsSection() {
}
const result = await updateProfile({ [key]: value });
if (result?.success) {
toast.success("Preference saved.", { description: "Reload the page for this to take effect." });
toast("Preference saved.", {
description: "Reload the page for this to take effect.",
action: {
label: "Close",
onClick: () => {}
}
});
}
};
@@ -380,14 +301,26 @@ function AdvertisementsSection() {
setConfirmPopupOff(false);
const result = await updateProfile({ show_popup_ads: false, show_other_ads: false });
if (result?.success) {
toast.success("Preference saved.", { description: "Reload the page for this to take effect." });
toast("Preference saved.", {
description: "Reload the page for this to take effect.",
action: {
label: "Close",
onClick: () => {}
}
});
}
};
const handleHideAllToggle = async (hide) => {
const result = await updateProfile({ show_popup_ads: !hide, show_other_ads: !hide });
if (result?.success) {
toast.success("Preference saved.", { description: "Reload the page for this to take effect." });
toast("Preference saved.", {
description: "Reload the page for this to take effect.",
action: {
label: "Close",
onClick: () => {}
}
});
}
};
@@ -454,69 +387,81 @@ function AdvertisementsSection() {
}
// ─── Delete Account ───────────────────────────────────────────────────────────
function DeleteAccountSection({ logout }) {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const handleDelete = async () => {
setLoading(true);
try {
await api.delete("/client/profile");
toast.success("Account deleted. Goodbye!");
await logout();
navigate("/login");
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not delete account.");
setLoading(false);
}
};
return (
<>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="text-sm font-medium text-destructive">Delete account</p>
<p className="text-xs text-muted-foreground">
Permanently remove your account and all associated data. This action cannot be undone.
</p>
</div>
<Button
variant="destructive"
size="sm"
className="shrink-0"
onClick={() => setOpen(true)}
>
<Trash2 className="size-3.5 mr-1.5" />
Delete account
</Button>
</div>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete your account?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete your account and sign you out of all sessions.
Your data cannot be recovered after deletion.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={loading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{loading ? "Deleting…" : "Yes, delete my account"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
// Disabled while still in development — keep implemented for when we're ready
// to expose self-service account deletion.
//
// function DeleteAccountSection({ logout }) {
// const navigate = useNavigate();
// const [open, setOpen] = useState(false);
// const [loading, setLoading] = useState(false);
//
// const handleDelete = async () => {
// setLoading(true);
// try {
// await api.delete("/client/profile");
// toast("Account deleted. Goodbye!", {
// action: {
// label: "Close",
// onClick: () => {}
// }
// });
// await logout();
// navigate("/login");
// } catch (err) {
// toast(err?.response?.data?.message ?? "Could not delete account.", {
// action: {
// label: "Close",
// onClick: () => {}
// }
// });
// setLoading(false);
// }
// };
//
// return (
// <>
// <div className="flex items-start justify-between gap-4">
// <div className="space-y-1">
// <p className="text-sm font-medium text-destructive">Delete account</p>
// <p className="text-xs text-muted-foreground">
// Permanently remove your account and all associated data. This action cannot be undone.
// </p>
// </div>
// <Button
// variant="destructive"
// size="sm"
// className="shrink-0"
// onClick={() => setOpen(true)}
// >
// <Trash2 className="size-3.5 mr-1.5" />
// Delete account
// </Button>
// </div>
//
// <AlertDialog open={open} onOpenChange={setOpen}>
// <AlertDialogContent>
// <AlertDialogHeader>
// <AlertDialogTitle>Delete your account?</AlertDialogTitle>
// <AlertDialogDescription>
// This will permanently delete your account and sign you out of all sessions.
// Your data cannot be recovered after deletion.
// </AlertDialogDescription>
// </AlertDialogHeader>
// <AlertDialogFooter>
// <AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
// <AlertDialogAction
// onClick={handleDelete}
// disabled={loading}
// className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
// >
// {loading ? "Deleting…" : "Yes, delete my account"}
// </AlertDialogAction>
// </AlertDialogFooter>
// </AlertDialogContent>
// </AlertDialog>
// </>
// );
// }
// ─── Page ─────────────────────────────────────────────────────────────────────
@@ -539,21 +484,13 @@ export default function AccountSettings() {
<SubscriptionSection />
</Section>
<Section icon={Mail} title="Newsletter" description="Choose what emails you want to receive from us.">
<NewsletterSection />
</Section>
<Section icon={Info} title="Course Notices" description="Control informational notices shown while studying.">
<CourseNoticesSection />
</Section>
<Section icon={Megaphone} title="Advertisements" description="Control which advertisements you see across the platform.">
<AdvertisementsSection />
</Section>
<Section icon={Trash2} title="Danger Zone" description="Irreversible account actions.">
{/* <Section icon={Trash2} title="Danger Zone" description="Irreversible account actions.">
<DeleteAccountSection logout={logout} />
</Section>
</Section> */}
</div>
</div>
);
+24 -4
View File
@@ -122,7 +122,12 @@ const Checkout = () => {
if (!wasCancelled) return;
const orderId = searchParams.get("token");
if (orderId) cancelOrder(orderId);
toast.info("PayPal checkout was cancelled.");
toast("PayPal checkout was cancelled.", {
action: {
label: "Close",
onClick: () => {}
}
});
navigate(`/plans/checkout?plan_id=${planId}`, { replace: true });
}, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -153,9 +158,19 @@ const Checkout = () => {
const result = await validatePromo(plan.plan_id, promoCode.trim().toUpperCase());
if (result?.valid) {
setPromoResult(result);
toast.success("Promo code applied.");
toast("Promo code applied.", {
action: {
label: "Close",
onClick: () => {}
}
});
} else {
toast.error(result?.reason ?? "Invalid promo code.");
toast(result?.reason ?? "Invalid promo code.", {
action: {
label: "Close",
onClick: () => {}
}
});
}
};
@@ -171,7 +186,12 @@ const Checkout = () => {
);
if (!order) return;
const approvalUrl = order.approval_url;
if (!approvalUrl) { toast.error("Could not get PayPal approval URL."); return; }
if (!approvalUrl) { toast("Could not get PayPal approval URL.", {
action: {
label: "Close",
onClick: () => {}
}
}); return; }
window.location.href = approvalUrl;
};
+12 -2
View File
@@ -68,7 +68,12 @@ export default function CourseCheckout() {
if (!wasCancelled) return;
const orderId = searchParams.get("token");
if (orderId) cancelCourseOrder(orderId);
toast.info("Payment was cancelled.");
toast("Payment was cancelled.", {
action: {
label: "Close",
onClick: () => {}
}
});
navigate(`/course/${courseId}/checkout`, { replace: true });
}, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -76,7 +81,12 @@ export default function CourseCheckout() {
if (!course?.product?.id) return;
const order = await createCourseOrder(course.product.id);
if (!order) return;
if (!order.approval_url) { toast.error("Could not get PayPal approval URL."); return; }
if (!order.approval_url) { toast("Could not get PayPal approval URL.", {
action: {
label: "Close",
onClick: () => {}
}
}); return; }
window.location.href = order.approval_url;
};
+113 -86
View File
@@ -5,8 +5,9 @@ import api from "@/utils/api.util";
import {
House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon,
SendHorizonal, CheckCheck, CheckCircle2, Clock, FileQuestion, ClipboardList,
Hourglass,
Hourglass, Check,
} from "lucide-react";
import { cn } from "@/lib/utils";
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -44,7 +45,6 @@ function formatDuration(seconds = 0) {
// ─── Spine / card helpers ──────────────────────────────────────────────────────
const INTRO_HEIGHT = 50;
const CX = 0;
const useVisibleNodes = (refs, count) => {
const [visible, setVisible] = useState(new Set());
@@ -388,66 +388,69 @@ const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, badgeColo
const lastMid = mids.length ? mids[mids.length - 1] : 0;
const svgH = lastMid + 40;
const drawnTo = maxVisible >= 0 && mids[maxVisible] ? mids[maxVisible] : 0;
const isIssued = !!certificate;
return (
<div ref={wrapRef} className="flex gap-5 px-4">
{/* Spine */}
<div className="relative flex-shrink-0 w-4" style={{ height: svgH }}>
<div className="relative flex-shrink-0 w-7" style={{ height: svgH }}>
{mids.length > 0 && (
<svg
className="absolute top-0 left-0 overflow-visible xs:hidden lg:block"
width={12}
height={svgH}
xmlns="http://www.w3.org/2000/svg"
>
<line x1={CX} y1={0} x2={CX} y2={svgH} stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" opacity={0.08} />
{Array.from({ length: 10 }).map((_, i) => {
const y1 = (mids[0] / 10) * i;
const y2 = (mids[0] / 10) * (i + 1);
const revealed = drawnTo >= y2;
return (
<>
<svg
className="absolute top-0 left-1/2 -translate-x-1/2 overflow-visible text-border xs:hidden lg:block"
width={2}
height={svgH}
xmlns="http://www.w3.org/2000/svg"
>
<line x1={1} y1={0} x2={1} y2={svgH} stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
{Array.from({ length: 10 }).map((_, i) => {
const y1 = (mids[0] / 10) * i;
const y2 = (mids[0] / 10) * (i + 1);
const revealed = drawnTo >= y2;
return (
<motion.line
key={`intro-${i}`}
x1={1} y1={y1} x2={1} y2={y2}
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
animate={{ opacity: revealed ? 1 : 0.3 }}
transition={{ duration: 0.4, ease: "easeOut" }}
/>
);
})}
{mids[0] != null && drawnTo > mids[0] && (
<motion.line
key={`intro-${i}`}
x1={CX} y1={y1} x2={CX} y2={y2}
x1={1} y1={mids[0]} x2={1} y2={drawnTo}
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
animate={{ opacity: revealed ? (i + 1) / 10 : 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
initial={{ opacity: 0.3 }} animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
/>
);
})}
{mids[0] != null && drawnTo > mids[0] && (
<motion.line
x1={CX} y1={mids[0]} x2={CX} y2={drawnTo}
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
/>
)}
)}
</svg>
{mids.map((mid, i) => {
const visible = visibleNodes.has(i);
// Last node (certificate) gets a gold fill
const isCert = i === totalNodes - 1;
return (
<g key={`node-${i}`}>
<motion.circle
cx={CX} cy={mid} r={isCert ? 7 : 5}
fill={isCert ? "#D4A017" : "currentColor"}
stroke={isCert ? "#D4A017" : "currentColor"}
strokeWidth="1.5"
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: visible ? 1 : 0, scale: visible ? 1 : 0 }}
transition={{ type: "spring", stiffness: 400, damping: 18, delay: 0.05 }}
style={{ transformOrigin: `${CX}px ${mid}px` }}
/>
</g>
<motion.div
key={`node-${i}`}
className={cn(
"absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 w-7 h-7 rounded-full border bg-background flex items-center justify-center text-xs font-medium xs:hidden lg:flex",
isCert && isIssued ? "border-emerald-500 text-emerald-500" : "border-border text-muted-foreground"
)}
style={{ top: mid }}
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: visible ? 1 : 0, scale: visible ? 1 : 0 }}
transition={{ type: "spring", stiffness: 400, damping: 18, delay: 0.05 }}
>
{isCert ? <Check className="size-3.5" /> : i + 1}
</motion.div>
);
})}
</svg>
</>
)}
</div>
{/* Cards */}
<div className="xs:-ml-10 lg:-ml-0 flex flex-col gap-6 flex-1 max-w-3xl" style={{ paddingTop: INTRO_HEIGHT }}>
<div className="xs:-ml-12 lg:-ml-0 flex flex-col gap-6 flex-1 max-w-3xl" style={{ paddingTop: INTRO_HEIGHT }}>
{nodes.map((node, ni) => {
const delay = ni * 0.05;
const nodeRef = (el) => (cardRefs.current[ni] = el);
@@ -543,7 +546,7 @@ const CourseDetails = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [courseId]);
const bannerAd = advertisements["course_details.banner"] ?? null;
const bannerAd = advertisements["course_details.banner"] ?? null;
const sidebarAd = advertisements["course_details.sidebar"] ?? null;
// Resolve badge image once course loads — issue a client stream token for
@@ -563,7 +566,12 @@ const CourseDetails = () => {
}, [course?.badge_asset_id, course?.badge_image_url]);
if (courseBlocked) {
toast.error("You don't have access to this course. Upgrade your plan.");
toast("You don't have access to this course. Upgrade your plan.", {
action: {
label: "Close",
onClick: () => { }
}
});
navigate("/course", { replace: true });
return null;
}
@@ -625,10 +633,20 @@ const CourseDetails = () => {
<div className="flex flex-col gap-6">
{/* Hero */}
<div className="bg-muted dark:bg-accent/50">
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-6 lg:px-4 lg:py-8">
<div><AppBreadcrumb items={items} /></div>
<div className="flex lg:flex-row items-start justify-between w-full">
<div className="bg-primary dark:bg-accent/50">
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-6 lg:px-4 lg:py-16">
<div>
<AppBreadcrumb
color={
{
link: { color: "text-white" },
page: { color: "text-white" }
}
}
items={items}
/>
</div>
<div className="flex lg:flex-row items-start justify-between w-full text-white">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
{(() => {
@@ -661,7 +679,7 @@ const CourseDetails = () => {
</div>
) : (
<Button
className="w-fit"
className="w-fit bg-blue-500"
onClick={() => navigate(`/course/${courseId}/unit`)}
>
{hasCompleted
@@ -688,49 +706,58 @@ const CourseDetails = () => {
{/* Body */}
<div className="lg:container lg:mx-auto flex flex-col gap-6 xs:px-6 xs:py-0 lg:py-8">
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex flex-col gap-4 flex-1 min-w-0">
<div className="font-bold text-2xl">About this course</div>
<div className="max-w-3xl space-y-4 lg:text-lg">
<p>{course?.description ?? ""}</p>
<div className="flex flex-col xs:gap-4 lg:gap-12 flex-1 min-w-0">
<div className="space-y-4">
<div className="font-bold text-2xl">About this course</div>
<div className="max-w-3xl space-y-4 text-muted-foreground lg:text-lg">
<p>{course?.description ?? ""}</p>
</div>
</div>
{/* Objectives */}
{course?.objectives?.length > 0 && (
<>
<div className="font-bold text-2xl">What you will learn</div>
<ul className="max-w-3xl list-disc list-inside space-y-1 lg:text-lg text-muted-foreground">
{course.objectives.map((obj) => (
<li key={obj.objective_id}>{obj.text}</li>
))}
</ul>
</>
)}
<div className="space-y-4">
{course?.objectives?.length > 0 && (
<div className="space-y-4">
<div className="font-bold text-2xl">What you will learn</div>
<ul className="max-w-3xl list-disc list-inside space-y-1 lg:text-lg text-muted-foreground">
{course.objectives.map((obj) => (
<li key={obj.objective_id}>{obj.text}</li>
))}
</ul>
</div>
)}
</div>
{/* Units — while content isn't ready, only Rewards is shown */}
{course?.units?.length > 0 && (
<>
<div className="font-bold text-2xl">
{contentNotReady ? "Rewards" : "Course content"}
</div>
<CourseUnits
units={course.units}
courseId={courseId}
courseTitle={course.title}
courseLevel={course.level}
badgeColor={course.badge_color ?? "purple"}
badgeImageUrl={badgeImageUrl}
isCompleted={isCompleted}
pendingCert={course.pending_certificate ?? null}
certificate={course.certificate ?? null}
assessment={course.assessment ?? null}
contentNotReady={contentNotReady}
/>
</>
)}
<div className="space-y-4">
{course?.units?.length > 0 && (
<>
<div className="font-bold text-2xl">
{contentNotReady ? "Rewards" : "Course content"}
</div>
<CourseUnits
units={course.units}
courseId={courseId}
courseTitle={course.title}
courseLevel={course.level}
badgeColor={course.badge_color ?? "purple"}
badgeImageUrl={badgeImageUrl}
isCompleted={isCompleted}
pendingCert={course.pending_certificate ?? null}
certificate={course.certificate ?? null}
assessment={course.assessment ?? null}
contentNotReady={contentNotReady}
/>
</>
)}
</div>
</div>
{/* Advertisement Sidebar */}
<aside className="hidden lg:block w-72 shrink-0 sticky top-24 h-fit">
<aside className="hidden lg:block w-72 shrink-0 sticky top-36 h-fit">
{adLoading["course_details.sidebar"] ? (
<SidebarSkeleton />
) : (
+18 -23
View File
@@ -242,7 +242,7 @@ const CoursesList = () => {
<div className="flex items-center xs:flex-col lg:flex-row gap-4">
<Input
placeholder="Search courses..."
className="w-full bg-card lg:max-w-64"
className="w-full bg-card lg:max-w-64 text-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
/>
@@ -272,31 +272,26 @@ const CoursesList = () => {
))}
</SelectContent>
</Select>
{allCategories.length > 0 && (
<Select value={categoryFilter} onValueChange={(v) => { setCategoryFilter(v); setCurrentPage(1); }}>
<SelectTrigger className="w-full lg:w-48 bg-card">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All">All Categories</SelectItem>
{allCategories.map((cat) => (
<SelectItem key={cat.id} value={String(cat.id)}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
</div>
{/* Product category chips */}
{allCategories.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<button
className={`px-3 py-1 rounded-full text-sm border transition-colors ${categoryFilter === "All" ? "bg-primary text-primary-foreground border-primary" : "bg-card border-border hover:bg-muted"}`}
onClick={() => { setCategoryFilter("All"); setCurrentPage(1); }}
>
All
</button>
{allCategories.map((cat) => (
<button
key={cat.id}
className={`px-3 py-1 rounded-full text-sm border transition-colors ${categoryFilter === String(cat.id) ? "bg-primary text-primary-foreground border-primary" : "bg-card border-border hover:bg-muted"}`}
onClick={() => { setCategoryFilter(String(cat.id)); setCurrentPage(1); }}
>
{cat.name}
</button>
))}
</div>
)}
{/* Advertisement Banner */}
{adLoading["course_list.banner"] ? (
<BannerSkeleton />
@@ -315,7 +310,7 @@ const CoursesList = () => {
<p className="text-md">No courses found</p>
</div>
) : (
<div className="xs:pt-12 lg:pt-0 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-4">
<div className="xs:pt-12 lg:pt-0 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-4 xs:px-4 lg:px-0">
{paginated.map((course) => (
<CourseCard
key={course.course_id}
+18 -9
View File
@@ -207,7 +207,11 @@ const Client = () => {
const { courses, coursesLoading, getCourses } = useClientCourses();
const { myTier, getMyTier, tierMap } = useClientTiers();
const { groups, fetchGroups, loading: groupLoading } = useGroup();
const { advertisements, loading: adLoading, getActiveAdvertisements, handleAdCtaClick, dismissPopupForever } = useClientAdvertisements();
const {
advertisements, getActiveAdvertisements,
adLists, listLoading, getActiveAdvertisementList,
handleAdCtaClick, dismissPopupForever,
} = useClientAdvertisements();
const [modalOpen, setModalOpen] = useState(false);
const [selectedCourse, setSelectedCourse] = useState(null);
@@ -216,15 +220,19 @@ const Client = () => {
const userTier = myTier?.tier ?? "free";
const heroAd = advertisements["dashboard.hero"] ?? null;
const heroAds = adLists["dashboard.hero"] ?? [];
const popupAd = advertisements["dashboard.popup"] ?? null;
// Show welcome toast on first registration
useEffect(() => {
if (!navState?.justRegistered) return;
toast.success('Welcome to Philproperties!', {
toast('Welcome to Philproperties!', {
description: 'You earned the Early Access badge. Check your notifications for details.',
duration: 6000,
action: {
label: "Close",
onClick: () => {}
}
});
window.history.replaceState({}, '');
}, []);
@@ -238,11 +246,12 @@ const Client = () => {
fetchGroups();
}, [])
// ── Resolve active hero + popup ads once on mount ────────────────────────
// ── Resolve active popup ad + hero ad carousel once on mount ─────────────
useEffect(() => {
getActiveAdvertisements(["dashboard.hero", "dashboard.popup"]).then((result) => {
getActiveAdvertisements(["dashboard.popup"]).then((result) => {
if (result["dashboard.popup"]) setPopupOpen(true);
});
getActiveAdvertisementList("dashboard.hero");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -268,13 +277,13 @@ const Client = () => {
return (
<div>
<div className="my-20">
<div className="flex flex-col gap-8 justify-between lg:container lg:mx-auto pt-8 px-16">
<div className="flex flex-col gap-8 justify-between lg:container lg:mx-auto xs:pt-2 lg:pt-8 lg:px-16 xs:px-4 sm:px-6">
{/* ── Hero Advertisement ── */}
{adLoading["dashboard.hero"] ? (
{/* ── Hero Advertisement Carousel ── */}
{listLoading["dashboard.hero"] ? (
<HeroSkeleton />
) : (
<Hero ad={heroAd} onCtaClick={handleAdCtaClick} />
<Hero ads={heroAds} onCtaClick={handleAdCtaClick} />
)}
{/* ── My Groups ── */}
+6 -1
View File
@@ -30,7 +30,12 @@ const CertificateCard = ({ courseTitle, issuedAt, courseUuid, badgeColor, badgeI
a.click();
URL.revokeObjectURL(url);
} catch {
toast.error("Could not download certificate.");
toast("Could not download certificate.", {
action: {
label: "Close",
onClick: () => {}
}
});
} finally {
setDownloading(false);
}
+14 -4
View File
@@ -141,8 +141,18 @@ export default function Notifications() {
const ok = await clearAll();
setClearing(false);
setClearOpen(false);
if (ok) toast.success("All notifications cleared.");
else toast.error("Could not clear notifications.");
if (ok) toast("All notifications cleared.", {
action: {
label: "Close",
onClick: () => {}
}
});
else toast("Could not clear notifications.", {
action: {
label: "Close",
onClick: () => {}
}
});
}
const rangeStart = pagination.total === 0 ? 0 : (pagination.page - 1) * pagination.limit + 1;
@@ -176,7 +186,7 @@ export default function Notifications() {
Mark all as read
</Button>
)}
<Button
{/* <Button
variant="outline"
size="sm"
className="gap-1.5 text-destructive hover:text-destructive"
@@ -185,7 +195,7 @@ export default function Notifications() {
>
<Trash2 className="size-3.5" />
Clear all
</Button>
</Button> */}
</div>
</div>
+12 -2
View File
@@ -385,12 +385,22 @@ export default function PlanList() {
setRefundLoading(true);
try {
const { data } = await api.post("/client/tiers/checkout/refund");
toast.success(data.message ?? "Refund processed. Your access has been revoked.");
toast(data.message ?? "Refund processed. Your access has been revoked.", {
action: {
label: "Close",
onClick: () => {}
}
});
setRefundPlan(null);
resetMyTier();
getMyTier();
} catch (err) {
toast.error(err?.response?.data?.message ?? "Refund failed. Please try again.");
toast(err?.response?.data?.message ?? "Refund failed. Please try again.", {
action: {
label: "Close",
onClick: () => {}
}
});
} finally {
setRefundLoading(false);
}
+7 -2
View File
@@ -78,7 +78,7 @@ function resolveTierBadge(myTier) {
colorKey: category.color ?? "green",
label: category.badge_label ?? category.name ?? tier,
description: "",
information: "",
information: category.description ?? "",
};
}
}
@@ -134,7 +134,12 @@ const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid, badgeColor, badg
a.click();
URL.revokeObjectURL(url);
} catch {
toast.error("Could not download certificate.");
toast("Could not download certificate.", {
action: {
label: "Close",
onClick: () => {}
}
});
} finally {
setDownloading(false);
}
+6 -1
View File
@@ -507,7 +507,12 @@ const UnitList = () => {
useEffect(() => {
if (!completedTasks.length) return;
completedTasks.forEach((t) => {
toast.success(`"${t.task_name}" automatically turned in!`);
toast(`"${t.task_name}" automatically turned in!`, {
action: {
label: "Close",
onClick: () => {}
}
});
});
clearCompletedTasks();
}, [completedTasks]);
+21 -22
View File
@@ -114,19 +114,7 @@ const RequirementsStatusPanel = ({ requirements = [], latestCompletion, isVisite
<h2 className="font-semibold text-base">Requirements</h2>
<div className="flex flex-col gap-3">
{items.map(({ key, label, icon, getValue }) => {
const isProvided = reqTypes.includes(key);
if (!isProvided) {
return (
<div key={key} className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
{icon}
<span className="text-sm text-muted-foreground">{label}</span>
</div>
<Badge variant="secondary" className="text-xs">Not provided</Badge>
</div>
);
}
{provided.map(({ key, label, icon, getValue }) => {
const { done, total, binary } = getValue();
const complete = total > 0 && done >= total;
return (
@@ -152,9 +140,14 @@ const RequirementsStatusPanel = ({ requirements = [], latestCompletion, isVisite
<div className="flex flex-col gap-2 pt-2 border-t">
<div className="flex items-center justify-between">
<span className="text-sm">Overall progress</span>
<span className="text-sm">{completedCount} / {provided.length} done</span>
{provided.length > 0 && completedCount >= provided.length && (
<span className="text-sm">Completed</span>
)}
</div>
<Progress value={overallPercent} className="h-1.5" />
<Progress
value={overallPercent}
className={`h-1.5 ${provided.length > 0 && completedCount >= provided.length ? "[&>div]:bg-green-500" : ""}`}
/>
</div>
</div>
);
@@ -322,7 +315,12 @@ const ViewTask = () => {
}
if (!uploadedFiles.length) {
toast.error('No files were uploaded successfully.');
toast('No files were uploaded successfully.', {
action: {
label: "Close",
onClick: () => {}
}
});
return;
}
@@ -336,7 +334,12 @@ const ViewTask = () => {
setNote('');
setUploadState({ files: [], isUploading: false });
} catch (err) {
toast.error('Failed to submit. Please try again.');
toast('Failed to submit. Please try again.', {
action: {
label: "Close",
onClick: () => {}
}
});
} finally {
setSubmitting(false);
}
@@ -410,7 +413,7 @@ const ViewTask = () => {
<div className="grid lg:grid-cols-[1fr_350px] gap-6 items-start">
{/* ── Left ──────────────────────────────────────────────── */}
<div className="flex flex-col gap-6 xs:order-2 lg:order-0 min-w-0 w-full max-w-full">
<div className="flex flex-col gap-6 xs:order-1 lg:order-0 min-w-0 w-full max-w-full">
{/* Task header card */}
<div className="border rounded-lg bg-card overflow-hidden">
@@ -448,10 +451,6 @@ const ViewTask = () => {
{/* Requirements section */}
{!isResolving && requirements.length > 0 && (
<>
<div className="flex items-center gap-4 text-lg">
<h1>Requirements</h1>
</div>
{/* visit_link */}
{visitLinkReqs.length > 0 && (
<VisitLink
+3 -3
View File
@@ -20,7 +20,7 @@ export default function Footer() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 lg:gap-12">
{/* Resources */}
<div>
<h3 className="font-semibold text-primary mb-4 text-2xl tracking-tighter">Resources</h3>
<h3 className="font-semibold text-primary dark:text-white mb-4 text-2xl tracking-tighter">Resources</h3>
<ul className="space-y-3 text-muted-foreground">
<li>
<Link to="">Philpro Learnings</Link>
@@ -39,7 +39,7 @@ export default function Footer() {
{/* Company */}
<div>
<h3 className="font-semibold text-primary mb-4 text-2xl tracking-tighter">Company</h3>
<h3 className="font-semibold text-primary dark:text-white mb-4 text-2xl tracking-tighter">Company</h3>
<ul className="space-y-3 text-muted-foreground">
<li>
<Link to="">Philpro Learnings</Link>
@@ -58,7 +58,7 @@ export default function Footer() {
{/* Socials */}
<div>
<h3 className="font-semibold text-primary mb-4 text-2xl tracking-tighter">Socials</h3>
<h3 className="font-semibold text-primary dark:text-white mb-4 text-2xl tracking-tighter">Socials</h3>
<ul className="space-y-3 text-muted-foreground">
<li>
<Link to="">Philpro Learnings</Link>
+19 -14
View File
@@ -49,7 +49,7 @@ function LandingPage() {
<GitCompare /> Alpha Testing
</Badge>
</div>
<h1 className="xs:text-5xl lg:text-6xl font-bold tracking-tighter leading-tight text-primary max-w-2xl md:text-center break-words">
<h1 className="xs:text-5xl lg:text-6xl font-bold tracking-tighter leading-tight text-primary dark:text-white max-w-2xl md:text-center break-words">
Fueling Growth, Elevate your performance
</h1>
<p className="text-muted-foreground text-xl">Access the application, Achieve the transformation.</p>
@@ -86,7 +86,7 @@ function LandingPage() {
/>
To-do
<Badge
className="bg-primary dark:bg-blue-500 text-primary-foreground ms-2 min-w-5 rounded-full transition-opacity group-data-[state=inactive]:opacity-50 dark:group-data-[state=inactive]:bg-white"
className="bg-primary dark:bg-blue-500 text-primary-foreground dark:group-data-[state=active]:text-white ms-2 min-w-5 rounded-full transition-opacity group-data-[state=inactive]:opacity-50 dark:group-data-[state=inactive]:bg-white dark:group-data-[state=inactive]:text-primary"
variant="secondary"
>3</Badge>
</TabsTrigger>
@@ -100,7 +100,7 @@ function LandingPage() {
/>
Pending
<Badge
className="bg-primary dark:bg-blue-500 text-primary-foreground ms-2 min-w-5 rounded-full transition-opacity group-data-[state=inactive]:opacity-50 dark:group-data-[state=inactive]:bg-white"
className="bg-primary dark:bg-blue-500 text-primary-foreground dark:group-data-[state=active]:text-white ms-2 min-w-5 rounded-full transition-opacity group-data-[state=inactive]:opacity-50 dark:group-data-[state=inactive]:bg-white dark:group-data-[state=inactive]:text-primary"
variant="secondary"
>8</Badge>
</TabsTrigger>
@@ -114,7 +114,7 @@ function LandingPage() {
/>
Completed
<Badge
className="bg-primary dark:bg-blue-500 text-primary-foreground ms-2 min-w-5 -mr-1 rounded-full transition-opacity group-data-[state=inactive]:opacity-50 dark:group-data-[state=inactive]:bg-white"
className="bg-primary dark:bg-blue-500 text-primary-foreground dark:group-data-[state=active]:text-white ms-2 min-w-5 -mr-1 rounded-full transition-opacity group-data-[state=inactive]:opacity-50 dark:group-data-[state=inactive]:bg-white dark:group-data-[state=inactive]:text-primary"
variant="secondary"
>20</Badge>
</TabsTrigger>
@@ -141,7 +141,7 @@ function LandingPage() {
</div>
</div>
{/* Call to Action */}
<div id="about" className="xs:p-8 lg:p-12 flex flex-col xs:text-2xl lg:text-4xl font-bold tracking-tighter text-primary gap-8 hover:bg-muted dark:hover:bg-muted/20">
<div id="about" className="xs:p-8 lg:p-12 flex flex-col xs:text-2xl lg:text-4xl font-bold tracking-tighter text-primary dark:text-white gap-8 hover:bg-muted dark:hover:bg-muted/20">
<div>
“The Sales Training and Recruitment (STAR) makes building a winning sales team simple. From hiring the right people to fast-tracking their skills, it combines smart recruitment, clear onboarding, and practical training to create confident, high-performing professionals.”
</div>
@@ -153,15 +153,15 @@ function LandingPage() {
{/* For sales, why choose us? */}
<div className="grid xs:grid-cols-1 lg:grid-cols-3 border-t">
<div className="flex flex-col gap-4 xs:border-b lg:border-r xs:p-8 lg:p-12 hover:bg-muted dark:hover:bg-muted/20">
<h1 className="text-primary font-bold tracking-tighter text-4xl">Hire Smarter</h1>
<h1 className="text-primary dark:text-white font-bold tracking-tighter text-4xl">Hire Smarter</h1>
<p className="text-muted-foreground">Find the right talent faster with a streamlined recruitment process.</p>
</div>
<div className="flex flex-col gap-4 xs:border-b lg:border-r xs:p-8 lg:p-12 hover:bg-muted dark:hover:bg-muted/20">
<h1 className="text-primary font-bold tracking-tighter text-4xl">Train Better</h1>
<h1 className="text-primary dark:text-white font-bold tracking-tighter text-4xl">Train Better</h1>
<p className="text-muted-foreground">Equip every recruit with clear onboarding, mandatory modules, and practical sales training.</p>
</div>
<div className="flex flex-col gap-4 xs:p-8 lg:p-12 xs:border-b hover:bg-muted dark:hover:bg-muted/20">
<h1 className="text-primary font-bold tracking-tighter text-4xl">Grow</h1>
<h1 className="text-primary dark:text-white font-bold tracking-tighter text-4xl">Grow</h1>
<p className="text-muted-foreground">Build confident professionals, reduce turnover, and boost long-term sales performance.</p>
</div>
</div>
@@ -170,7 +170,7 @@ function LandingPage() {
<div className="grid xs:grid-cols-1 lg:grid-cols-2">
<div className="flex flex-col gap-4 lg:border-r xs:p-8 lg:p-12 hover:bg-muted dark:hover:bg-muted/20">
<div className="space-y-4">
<h1 className="text-primary font-bold tracking-tighter text-4xl">Frequently Asked Questions</h1>
<h1 className="text-primary dark:text-white font-bold tracking-tighter text-4xl">Frequently Asked Questions</h1>
<p className="text-muted-foreground">Here are useful questions.</p>
</div>
<div className="space-y-4">
@@ -187,14 +187,14 @@ function LandingPage() {
</div>
<div id="contacts" className="flex flex-col gap-4 xs:p-8 lg:p-12 hover:bg-muted dark:hover:bg-muted/20">
<div className="space-y-4">
<h1 className="text-primary font-bold tracking-tighter text-4xl">Contact Us</h1>
<h1 className="text-primary dark:text-white font-bold tracking-tighter text-4xl">Contact Us</h1>
<p className="text-muted-foreground">Find the right talent faster with a streamlined recruitment process.</p>
</div>
<div className="space-y-4">
{ContactData.map(({ id, icon: Icon, label, value }) => (
<div
key={id}
className="flex items-center justify-between bg-primary dark:bg-blue-600 rounded-md px-4 py-2 text-sm text-primary-foreground dark:text-white"
className="flex items-center justify-between bg-primary dark:bg-blue-600 rounded-md px-4 py-2 text-sm text-primary dark:text-white"
>
<div className="flex items-center flex-wrap gap-2">
<Icon className="size-4" />
@@ -205,7 +205,12 @@ function LandingPage() {
<Button
variant="ghost"
size="icon"
onClick={() => handleCopy(value) + toast.success("Copied text successfully!")}
onClick={() => handleCopy(value) + toast("Copied text successfully!", {
action: {
label: "Close",
onClick: () => {}
}
})}
>
{copied === value ? (
<Check className="size-4" />
@@ -220,7 +225,7 @@ function LandingPage() {
</div>
{/* Closing Remarks */}
<div className="xs:p-8 lg:p-12 flex flex-col xs:text-4xl leading-tight border-t font-bold tracking-tighter text-primary gap-8 hover:bg-muted dark:hover:bg-muted/20">
<div className="xs:p-8 lg:p-12 flex flex-col xs:text-4xl leading-tight border-t font-bold tracking-tighter text-primary dark:text-white gap-8 hover:bg-muted dark:hover:bg-muted/20">
<div>
Ready to supercharge? {<br />} Start by leveraging your limits.
</div>
@@ -228,7 +233,7 @@ function LandingPage() {
</div>
</div>
</Fragment>
)
);
}
export default LandingPage;