testing 101

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-30 19:32:00 +08:00
parent fe47bdea3c
commit 17326b2c2e
78 changed files with 4804 additions and 1054 deletions
+1 -1
View File
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier same "printed page" as the copyright notice for easier
identification within third-party archives. identification within third-party archives.
Copyright [yyyy] [name of copyright owner] Copyright 2025 [Kenneth Obsequio and Russell Obsequio]
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
+2 -2
View File
@@ -47,7 +47,7 @@ import { PageMeta } from '@/contexts/MetadataContext'
export default function CourseList() { export default function CourseList() {
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta <PageMeta
title="Courses - STARR" title="Courses - STARR"
description="Browse and manage your training courses." description="Browse and manage your training courses."
@@ -99,7 +99,7 @@ export default function EditUnit() {
// ... fetch and setUnitTitle on load // ... fetch and setUnitTitle on load
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta title={unitTitle ? `Edit: ${unitTitle} - STARR` : undefined} /> <PageMeta title={unitTitle ? `Edit: ${unitTitle} - STARR` : undefined} />
{/* rest of page */} {/* rest of page */}
</section> </section>
+12 -6
View File
@@ -2,16 +2,18 @@ import { useEffect } from 'react';
import { AuthProvider, useAuth, decodeToken } from './contexts/AuthContext'; import { AuthProvider, useAuth, decodeToken } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { DateTimePreferenceProvider } from './contexts/DateTimePreferenceContext'; import { DateTimePreferenceProvider } from './contexts/DateTimePreferenceContext';
import { CurrencyPreferenceProvider } from './contexts/CurrencyPreferenceContext';
import { Helmet, HelmetProvider } from "react-helmet-async"; import { Helmet, HelmetProvider } from "react-helmet-async";
import { TooltipProvider } from './components/ui/tooltip'; import { TooltipProvider } from './components/ui/tooltip';
import { setAuthInterceptor } from './utils/api.util'; import { setAuthInterceptor } from './utils/api.util';
import { attachCsrfInterceptor, fetchCsrfToken } from './utils/csrf.util'; import { attachCsrfInterceptor, fetchCsrfToken } from './utils/csrf.util';
import AppRouter from './routes/AppRouter'; import AppRouter from './routes/AppRouter';
import AppLoadingScreen from './components/AppLoadingScreen';
import './index.css'; import './index.css';
import 'react-photo-view/dist/react-photo-view.css'; import 'react-photo-view/dist/react-photo-view.css';
function AppWithAuth() { function AppWithAuth() {
const { accessTokenRef, setAccessToken, setUser, restoreSession, logout } = useAuth() const { accessTokenRef, setAccessToken, setUser, restoreSession, logout, loading } = useAuth()
useEffect(() => { useEffect(() => {
attachCsrfInterceptor() attachCsrfInterceptor()
@@ -56,6 +58,8 @@ function AppWithAuth() {
restoreSession() restoreSession()
}, []) }, [])
if (loading) return <AppLoadingScreen />
return <AppRouter /> return <AppRouter />
} }
@@ -80,11 +84,13 @@ export default function App() {
</Helmet> </Helmet>
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme"> <ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
<DateTimePreferenceProvider> <DateTimePreferenceProvider>
<TooltipProvider delayDuration={300}> <CurrencyPreferenceProvider>
<AuthProvider> <TooltipProvider delayDuration={300}>
<AppWithAuth /> <AuthProvider>
</AuthProvider> <AppWithAuth />
</TooltipProvider> </AuthProvider>
</TooltipProvider>
</CurrencyPreferenceProvider>
</DateTimePreferenceProvider> </DateTimePreferenceProvider>
</ThemeProvider> </ThemeProvider>
</HelmetProvider> </HelmetProvider>
+12
View File
@@ -0,0 +1,12 @@
import { Spinner } from './ui/spinner'
const APP_NAME = import.meta.env.VITE_APP_NAME ?? 'STARR'
export default function AppLoadingScreen() {
return (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-background">
<Spinner className="size-10 text-primary" />
<p className="mt-4 text-sm text-muted-foreground tracking-wide">Loading {APP_NAME}...</p>
</div>
)
}
+18 -4
View File
@@ -125,8 +125,12 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
useEffect(() => { useEffect(() => {
if (!assets.length) return; if (!assets.length) return;
// Only request tokens for S3 assets we don't already have a URL for —
// this prevents duplicate POST /tokens when the assets list triggers
// this effect more than once per sheet open (e.g., two fetch effects
// both reacting to open mounting, producing two assets updates).
const s3Ids = assets const s3Ids = assets
.filter((a) => a.storage_provider === "s3" && !a.thumbnail_url && !a.file_url) .filter((a) => a.storage_provider === "s3" && !streamUrls[String(a.asset_id)])
.map((a) => a.asset_id); .map((a) => a.asset_id);
if (!s3Ids.length) return; if (!s3Ids.length) return;
@@ -135,10 +139,12 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
api.post("/admin/media/tokens", { asset_ids: s3Ids }) api.post("/admin/media/tokens", { asset_ids: s3Ids })
.then(({ data }) => { .then(({ data }) => {
if (cancelled) return; if (cancelled) return;
const tokens = data.data?.tokens ?? {}; const tokens = data.data?.tokens ?? {};
const thumbnails = data.data?.thumbnails ?? {};
const urls = {}; const urls = {};
for (const [id, token] of Object.entries(tokens)) { for (const [id, token] of Object.entries(tokens)) {
urls[id] = `${STREAM_BASE}/${token}`; // Prefer presigned thumbnail URL (faster, direct); fall back to stream proxy
urls[id] = thumbnails[id] ?? `${STREAM_BASE}/${token}`;
} }
setStreamUrls((prev) => ({ ...prev, ...urls })); setStreamUrls((prev) => ({ ...prev, ...urls }));
}) })
@@ -177,7 +183,15 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
const handleSelect = (asset) => { const handleSelect = (asset) => {
setSelected(asset.asset_id); setSelected(asset.asset_id);
onSelect(asset); // Pass the resolved stream/presigned URL as a second arg so callers
// (e.g. badge image picker) can use the authenticated URL directly
// rather than falling back to asset.file_url which is a private CDN
// key that the browser cannot load without S3 credentials.
const resolvedUrl = streamUrls[String(asset.asset_id)]
?? asset.thumbnail_url
?? asset.file_url
?? null;
onSelect(asset, resolvedUrl);
onOpenChange(false); onOpenChange(false);
}; };
+2 -2
View File
@@ -13,7 +13,7 @@
* disabled? : boolean * disabled? : boolean
* placeholder?: string * placeholder?: string
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
import { useEffect, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import api from '@/utils/api.util'; import api from '@/utils/api.util';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -60,7 +60,7 @@ export default function GroupMultiSelect({
}, [groupsProp]); }, [groupsProp]);
// ── Position portal dropdown under trigger ──────────────────────────────── // ── Position portal dropdown under trigger ────────────────────────────────
useEffect(() => { useLayoutEffect(() => {
if (!open || !triggerRef.current) return; if (!open || !triggerRef.current) return;
const reposition = () => { const reposition = () => {
+89
View File
@@ -0,0 +1,89 @@
import { useState } from "react";
import { format } from "date-fns";
import { CalendarDays, Clock, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
/**
* DateTimePicker
*
* value — ISO string | null
* onChange — (isoString | null) => void
*/
export function DateTimePicker({ value, onChange, placeholder = "Pick date & time", disabled }) {
const [open, setOpen] = useState(false);
const dateValue = value ? new Date(value) : undefined;
const timeStr = dateValue
? `${String(dateValue.getHours()).padStart(2, "0")}:${String(dateValue.getMinutes()).padStart(2, "0")}`
: "00:00";
const handleDaySelect = (day) => {
if (!day) { onChange(null); return; }
const base = dateValue ?? new Date();
day.setHours(base.getHours(), base.getMinutes(), 0, 0);
onChange(day.toISOString());
};
const handleTimeChange = (e) => {
const [h, m] = e.target.value.split(":").map(Number);
const base = dateValue ? new Date(dateValue) : new Date();
base.setHours(h, m, 0, 0);
onChange(base.toISOString());
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={disabled}
className={cn(
"w-full justify-start text-left font-normal",
!value && "text-muted-foreground",
)}
>
<CalendarDays className="mr-2 h-4 w-4 shrink-0" />
{dateValue ? format(dateValue, "MMM d, yyyy HH:mm") : placeholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dateValue}
onSelect={handleDaySelect}
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
autoFocus
/>
<div className="border-t px-3 py-2.5 flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
<Label className="text-xs text-muted-foreground shrink-0 w-8">Time</Label>
<Input
type="time"
value={timeStr}
onChange={handleTimeChange}
className="h-8 flex-1"
/>
{value && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => { onChange(null); setOpen(false); }}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
</PopoverContent>
</Popover>
);
}
+1 -1
View File
@@ -20,7 +20,7 @@ function Progress({
{...props}> {...props}>
<ProgressPrimitive.Indicator <ProgressPrimitive.Indicator
data-slot="progress-indicator" data-slot="progress-indicator"
className="size-full flex-1 bg-primary transition-all" className={cn("size-full flex-1 transition-all", value >= 100 ? "bg-green-500" : "bg-primary")}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} /> style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
); );
+16
View File
@@ -942,6 +942,20 @@ export function CoursesProvider({ children }) {
}), [request], }), [request],
); );
const fetchCourseAchievements = useCallback(
(courseId) => request(async () => {
const { data } = await api.get(`${BASE}/${courseId}/achievements`);
return (data?.data?.data ?? []).map((r) => r.achievement_key);
}), [request],
);
const syncCourseAchievements = useCallback(
(courseId, achievement_keys) => request(async () => {
await api.put(`${BASE}/${courseId}/achievements`, { achievement_keys });
toast.success("Rewards updated.");
}), [request],
);
const fetchCourseFieldValues = useCallback( const fetchCourseFieldValues = useCallback(
(field) => (field) =>
request(async () => { request(async () => {
@@ -1112,6 +1126,8 @@ export function CoursesProvider({ children }) {
syncCourseCategories, syncCourseCategories,
fetchInstructors, fetchInstructors,
syncInstructors, syncInstructors,
fetchCourseAchievements,
syncCourseAchievements,
}}> }}>
{children} {children}
</CoursesContext.Provider> </CoursesContext.Provider>
+60 -10
View File
@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -8,6 +8,7 @@ export function ClientTiersProvider({ children }) {
// ── My tier // ── My tier
const [myTier, setMyTier] = useState(null); const [myTier, setMyTier] = useState(null);
const [tierLoading, setTierLoading] = useState(false); const [tierLoading, setTierLoading] = useState(false);
const expiryTimerRef = useRef(null);
// ── Tier history // ── Tier history
const [tierHistory, setTierHistory] = useState([]); const [tierHistory, setTierHistory] = useState([]);
@@ -19,6 +20,7 @@ export function ClientTiersProvider({ children }) {
// ── Checkout // ── Checkout
const [checkoutLoading, setCheckoutLoading] = useState(false); const [checkoutLoading, setCheckoutLoading] = useState(false);
const [promoLoading, setPromoLoading] = useState(false);
// ── My payments // ── My payments
const [payments, setPayments] = useState([]); const [payments, setPayments] = useState([]);
@@ -33,18 +35,43 @@ export function ClientTiersProvider({ children }) {
// ─── Actions ──────────────────────────────────────────────────────────────── // ─── Actions ────────────────────────────────────────────────────────────────
const getMyTier = useCallback(async () => { const getMyTier = useCallback(async ({ silent = false } = {}) => {
setTierLoading(true); if (!silent) setTierLoading(true);
try { try {
const { data } = await api.get("/client/tiers/me"); const { data } = await api.get("/client/tiers/me");
setMyTier(data.data ?? null); const tier = data.data ?? null;
// Don't let a null response (e.g. stale browser-cache 304) overwrite a
// known-active tier — this prevents the "Free" flash after an upgrade.
setMyTier(prev => (tier === null && prev?.status === 'active') ? prev : tier);
if (tier?.just_expired) {
toast.warning("Your subscription has expired. You've been moved to the Free plan.");
}
} catch (err) { } catch (err) {
toast.error(err?.response?.data?.message ?? "Could not load tier."); if (!silent) toast.error(err?.response?.data?.message ?? "Could not load tier.");
} finally { } finally {
setTierLoading(false); if (!silent) setTierLoading(false);
} }
}, []); }, []);
// ── Single-shot expiry timer: fires exactly at expires_at, then re-fetches ──
useEffect(() => {
clearTimeout(expiryTimerRef.current);
if (!myTier?.expires_at || myTier.status !== "active") return;
const ms = new Date(myTier.expires_at).getTime() - Date.now();
if (ms <= 0) {
// Already past — fetch immediately (safety net, should rarely hit)
getMyTier({ silent: true });
return;
}
expiryTimerRef.current = setTimeout(() => {
getMyTier({ silent: true });
}, ms);
return () => clearTimeout(expiryTimerRef.current);
}, [myTier?.expires_at, myTier?.status, getMyTier]);
const getMyTierHistory = useCallback(async () => { const getMyTierHistory = useCallback(async () => {
setTierHistoryLoading(true); setTierHistoryLoading(true);
try { try {
@@ -69,12 +96,28 @@ export function ClientTiersProvider({ children }) {
} }
}, []); }, []);
// Returns { valid, code, type, value, discount, reason } from the server
const validatePromo = useCallback(async (plan_id, code, currency = null) => {
setPromoLoading(true);
try {
const payload = { plan_id, code };
if (currency) payload.currency = currency;
const { data } = await api.post('/client/tiers/promos/validate', payload);
return data.data ?? { valid: false, reason: 'No response from server.' };
} catch (err) {
return { valid: false, reason: err?.response?.data?.message ?? 'Invalid promo code.' };
} finally {
setPromoLoading(false);
}
}, []);
// Returns { payment_id, order_id, approval_url, amount, currency, ... } or null // Returns { payment_id, order_id, approval_url, amount, currency, ... } or null
const createOrder = useCallback(async (plan_id, promo_code = null) => { const createOrder = useCallback(async (plan_id, promo_code = null, currency = null) => {
setCheckoutLoading(true); setCheckoutLoading(true);
try { try {
const payload = { plan_id }; const payload = { plan_id };
if (promo_code) payload.promo_code = promo_code; if (promo_code) payload.promo_code = promo_code;
if (currency) payload.currency = currency;
const { data } = await api.post("/client/tiers/checkout/order", payload); const { data } = await api.post("/client/tiers/checkout/order", payload);
return data.data ?? null; return data.data ?? null;
} catch (err) { } catch (err) {
@@ -92,6 +135,9 @@ export function ClientTiersProvider({ children }) {
const { data } = await api.post("/client/tiers/checkout/capture", { order_id }); const { data } = await api.post("/client/tiers/checkout/capture", { order_id });
toast.success(data.message ?? "Payment successful. Tier activated."); toast.success(data.message ?? "Payment successful. Tier activated.");
setMyTier({ tier: data.data?.tier, expires_at: data.data?.expires_at, status: "active" }); setMyTier({ tier: data.data?.tier, expires_at: data.data?.expires_at, status: "active" });
// Refresh from server so the browser cache holds fresh Premium data — prevents
// subsequent getMyTier() calls from getting a stale 304 with the old Free/null response.
getMyTier({ silent: true });
return data.data ?? null; return data.data ?? null;
} catch (err) { } catch (err) {
toast.error(err?.response?.data?.message ?? "Payment capture failed."); toast.error(err?.response?.data?.message ?? "Payment capture failed.");
@@ -99,7 +145,7 @@ export function ClientTiersProvider({ children }) {
} finally { } finally {
setCheckoutLoading(false); setCheckoutLoading(false);
} }
}, []); }, [getMyTier]);
const cancelOrder = useCallback(async (order_id) => { const cancelOrder = useCallback(async (order_id) => {
if (!order_id) return false; if (!order_id) return false;
@@ -146,8 +192,11 @@ export function ClientTiersProvider({ children }) {
} }
}, []); }, []);
// slug → category object — derived, no extra state // slug → category object — stable reference; only recomputes when tierCategories array changes
const tierMap = Object.fromEntries(tierCategories.map((c) => [c.slug, c])); const tierMap = useMemo(
() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])),
[tierCategories]
);
// ─── Reset helpers ────────────────────────────────────────────────────────── // ─── Reset helpers ──────────────────────────────────────────────────────────
@@ -170,6 +219,7 @@ export function ClientTiersProvider({ children }) {
getMyTier, getMyTier,
getMyTierHistory, getMyTierHistory,
getPlans, getPlans,
validatePromo, promoLoading,
createOrder, createOrder,
captureOrder, captureOrder,
cancelOrder, cancelOrder,
@@ -0,0 +1,28 @@
import { createContext, useContext, useState, useCallback } from 'react';
const STORAGE_KEY = 'currency-preference';
const CurrencyPreferenceContext = createContext(null);
export function CurrencyPreferenceProvider({ children }) {
const [currency, setCurrencyState] = useState(
() => localStorage.getItem(STORAGE_KEY) ?? 'USD'
);
const setCurrency = useCallback((code) => {
localStorage.setItem(STORAGE_KEY, code);
setCurrencyState(code);
}, []);
return (
<CurrencyPreferenceContext.Provider value={{ currency, setCurrency }}>
{children}
</CurrencyPreferenceContext.Provider>
);
}
export function useCurrencyPreference() {
const ctx = useContext(CurrencyPreferenceContext);
if (!ctx) throw new Error('useCurrencyPreference must be used inside CurrencyPreferenceProvider');
return ctx;
}
+55
View File
@@ -0,0 +1,55 @@
import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext';
import { fmtCurrency } from '@/utils/datetime.util';
/**
* Returns bound currency formatters that automatically apply the user's
* preferred currency from CurrencyPreferenceContext.
*
* Usage:
* const { fmtPrice, currency, setCurrency } = useCurrency()
*
* // Format a plain amount in the user's preferred currency:
* fmtPrice(9.99)
*
* // Resolve + format a plan's localized price (plan.prices[] must be loaded):
* fmtPlanPrice(plan)
*/
export function useCurrency() {
const { currency, setCurrency } = useCurrencyPreference();
/** Format any amount in the user's preferred currency. */
function fmtPrice(amount) {
return fmtCurrency(amount, currency);
}
/**
* Resolve the correct price from a plan object and format it.
* plan.prices[] (localized overrides) takes priority over plan.price.
* Falls back to plan.price + plan.currency if no override exists.
*/
function fmtPlanPrice(plan) {
if (!plan) return '—';
const override = (plan.prices ?? []).find((p) => p.currency === currency);
if (override) return fmtCurrency(override.price, override.currency);
return fmtCurrency(plan.price, plan.currency);
}
/**
* Returns the effective { price, currency } for a plan without formatting.
* Useful when you need the raw numbers (e.g. sending to checkout).
*/
function resolvePlanPrice(plan) {
if (!plan) return { price: 0, currency: 'USD' };
const override = (plan.prices ?? []).find((p) => p.currency === currency);
if (override) return { price: Number(override.price), currency: override.currency };
return { price: Number(plan.price), currency: plan.currency };
}
return {
currency,
setCurrency,
fmtPrice,
fmtPlanPrice,
resolvePlanPrice,
};
}
@@ -0,0 +1,174 @@
const BADGE_GRADIENTS = {
// ── Original 12 ──────────────────────────────────────────────────────────────
purple: "linear-gradient(145deg, #a855f7cc 0%, #7e22cebf 55%, #3b0764b3 100%)",
green: "linear-gradient(145deg, #4ade80cc 0%, #15803dbf 55%, #14532db3 100%)",
rose: "linear-gradient(145deg, #fb7185cc 0%, #be123cbf 55%, #4c0519b3 100%)",
amber: "linear-gradient(145deg, #fbbf24cc 0%, #d97706bf 55%, #78350fb3 100%)",
sky: "linear-gradient(145deg, #38bdf8cc 0%, #0369a1bf 55%, #0c4a6eb3 100%)",
indigo: "linear-gradient(145deg, #818cf8cc 0%, #4338cabf 55%, #1e1b4bb3 100%)",
teal: "linear-gradient(145deg, #2dd4bfcc 0%, #0f766ebf 55%, #042f2eb3 100%)",
orange: "linear-gradient(145deg, #fb923ccc 0%, #c2410cbf 55%, #431407b3 100%)",
pink: "linear-gradient(145deg, #f472b6cc 0%, #be185dbf 55%, #500724b3 100%)",
cyan: "linear-gradient(145deg, #22d3eecc 0%, #0e7490bf 55%, #083344b3 100%)",
lime: "linear-gradient(145deg, #a3e635cc 0%, #4d7c0fbf 55%, #1a2e05b3 100%)",
slate: "linear-gradient(145deg, #94a3b8cc 0%, #475569bf 55%, #0f172ab3 100%)",
// ── Extended 18 ──────────────────────────────────────────────────────────────
red: "linear-gradient(145deg, #f87171cc 0%, #b91c1cbf 55%, #450a0ab3 100%)",
yellow: "linear-gradient(145deg, #fde047cc 0%, #a16207bf 55%, #3d1f00b3 100%)",
violet: "linear-gradient(145deg, #a78bfacc 0%, #6d28d9bf 55%, #2e1065b3 100%)",
fuchsia: "linear-gradient(145deg, #e879f9cc 0%, #a21cafbf 55%, #4a044eb3 100%)",
emerald: "linear-gradient(145deg, #34d399cc 0%, #047857bf 55%, #064e3bb3 100%)",
blue: "linear-gradient(145deg, #60a5facc 0%, #1d4ed8bf 55%, #1e3a8ab3 100%)",
zinc: "linear-gradient(145deg, #a1a1aacc 0%, #3f3f46bf 55%, #18181bb3 100%)",
stone: "linear-gradient(145deg, #d6d3d1cc 0%, #57534ebf 55%, #1c1917b3 100%)",
brown: "linear-gradient(145deg, #d97706cc 0%, #7c2d12bf 55%, #1c0902b3 100%)",
gold: "linear-gradient(145deg, #fcd34dcc 0%, #b45309bf 55%, #451a03b3 100%)",
navy: "linear-gradient(145deg, #93c5fdcc 0%, #1e40afbf 55%, #0f172ab3 100%)",
forest: "linear-gradient(145deg, #86efaccc 0%, #166534bf 55%, #052e16b3 100%)",
wine: "linear-gradient(145deg, #fda4afcc 0%, #9f1239bf 55%, #3b0014b3 100%)",
charcoal: "linear-gradient(145deg, #9ca3afcc 0%, #374151bf 55%, #111827b3 100%)",
midnight: "linear-gradient(145deg, #818cf8cc 0%, #312e81bf 55%, #0d0c1db3 100%)",
lavender: "linear-gradient(145deg, #ddd6fecc 0%, #7c3aedbf 55%, #2e1065b3 100%)",
salmon: "linear-gradient(145deg, #fca5a5cc 0%, #e11d48bf 55%, #4c0519b3 100%)",
mint: "linear-gradient(145deg, #a7f3d0cc 0%, #059669bf 55%, #022c22b3 100%)",
};
const SOLID_GRADIENTS = {
// ── Original 12 ──────────────────────────────────────────────────────────────
purple: "linear-gradient(145deg, #a855f7 0%, #7e22ce 55%, #3b0764 100%)",
green: "linear-gradient(145deg, #4ade80 0%, #15803d 55%, #14532d 100%)",
rose: "linear-gradient(145deg, #fb7185 0%, #be123c 55%, #4c0519 100%)",
amber: "linear-gradient(145deg, #fbbf24 0%, #d97706 55%, #78350f 100%)",
sky: "linear-gradient(145deg, #38bdf8 0%, #0369a1 55%, #0c4a6e 100%)",
indigo: "linear-gradient(145deg, #818cf8 0%, #4338ca 55%, #1e1b4b 100%)",
teal: "linear-gradient(145deg, #2dd4bf 0%, #0f766e 55%, #042f2e 100%)",
orange: "linear-gradient(145deg, #fb923c 0%, #c2410c 55%, #431407 100%)",
pink: "linear-gradient(145deg, #f472b6 0%, #be185d 55%, #500724 100%)",
cyan: "linear-gradient(145deg, #22d3ee 0%, #0e7490 55%, #083344 100%)",
lime: "linear-gradient(145deg, #a3e635 0%, #4d7c0f 55%, #1a2e05 100%)",
slate: "linear-gradient(145deg, #94a3b8 0%, #475569 55%, #0f172a 100%)",
// ── Extended 18 ──────────────────────────────────────────────────────────────
red: "linear-gradient(145deg, #f87171 0%, #b91c1c 55%, #450a0a 100%)",
yellow: "linear-gradient(145deg, #fde047 0%, #a16207 55%, #3d1f00 100%)",
violet: "linear-gradient(145deg, #a78bfa 0%, #6d28d9 55%, #2e1065 100%)",
fuchsia: "linear-gradient(145deg, #e879f9 0%, #a21caf 55%, #4a044e 100%)",
emerald: "linear-gradient(145deg, #34d399 0%, #047857 55%, #064e3b 100%)",
blue: "linear-gradient(145deg, #60a5fa 0%, #1d4ed8 55%, #1e3a8a 100%)",
zinc: "linear-gradient(145deg, #a1a1aa 0%, #3f3f46 55%, #18181b 100%)",
stone: "linear-gradient(145deg, #d6d3d1 0%, #57534e 55%, #1c1917 100%)",
brown: "linear-gradient(145deg, #d97706 0%, #7c2d12 55%, #1c0902 100%)",
gold: "linear-gradient(145deg, #fcd34d 0%, #b45309 55%, #451a03 100%)",
navy: "linear-gradient(145deg, #93c5fd 0%, #1e40af 55%, #0f172a 100%)",
forest: "linear-gradient(145deg, #86efac 0%, #166534 55%, #052e16 100%)",
wine: "linear-gradient(145deg, #fda4af 0%, #9f1239 55%, #3b0014 100%)",
charcoal: "linear-gradient(145deg, #9ca3af 0%, #374151 55%, #111827 100%)",
midnight: "linear-gradient(145deg, #818cf8 0%, #312e81 55%, #0d0c1d 100%)",
lavender: "linear-gradient(145deg, #ddd6fe 0%, #7c3aed 55%, #2e1065 100%)",
salmon: "linear-gradient(145deg, #fca5a5 0%, #e11d48 55%, #4c0519 100%)",
mint: "linear-gradient(145deg, #a7f3d0 0%, #059669 55%, #022c22 100%)",
};
export default function CourseBadge({ title = "Course Title", level, color = "purple", imageUrl, mini = false }) {
const levelLabel = level
? `${level.charAt(0).toUpperCase()}${level.slice(1)} Level`
: null;
// ── Mini variant: just the gradient + logo mark, no text/line ──────────────
if (mini) {
return (
<div className="relative w-12 h-12 rounded-xl overflow-hidden flex-shrink-0 select-none shadow-md">
{imageUrl ? (
<>
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${imageUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
<div
className="absolute inset-0"
style={{ background: BADGE_GRADIENTS[color] ?? BADGE_GRADIENTS.purple }}
/>
</>
) : (
<div
className="absolute inset-0"
style={{ background: SOLID_GRADIENTS[color] ?? SOLID_GRADIENTS.purple }}
/>
)}
{/* HIDE OUR LOGO */}
{/* <div className="relative h-full flex items-center justify-center">
<img src="/philpro-white-single.png" alt="Philpro" className="h-5 w-5 object-contain drop-shadow" />
</div> */}
</div>
);
}
return (
<div className="relative w-44 h-48 rounded-2xl overflow-hidden flex-shrink-0 select-none shadow-lg">
{/* ── Layer 1: Background — image when set, solid gradient otherwise ── */}
{imageUrl ? (
<div
className="absolute inset-0"
style={{
backgroundImage: `url(${imageUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
) : (
<div
className="absolute inset-0"
style={{ background: SOLID_GRADIENTS[color] ?? SOLID_GRADIENTS.purple }}
/>
)}
{/* ── Layer 2: Gradient colour overlay on top of the image ── */}
{imageUrl && (
<div
className="absolute inset-0"
style={{ background: BADGE_GRADIENTS[color] ?? BADGE_GRADIENTS.purple }}
/>
)}
{/* ── Layer 3: Soft glow in the upper-right corner (always present) ── */}
<div
className="absolute -top-6 -right-6 w-28 h-28 rounded-full pointer-events-none"
style={{
background: "radial-gradient(circle, rgba(255,255,255,0.15), transparent 70%)",
}}
/>
{/* ── Layer 4: Content ── */}
<div className="relative flex flex-col justify-between h-full p-3.5">
<div className="flex-1 flex flex-col justify-center gap-1.5 pr-2">
<p className="text-white font-bold text-sm leading-snug line-clamp-4 drop-shadow">
{title}
</p>
{levelLabel && (
<p className="text-white/60 text-[9.5px] font-semibold uppercase tracking-widest drop-shadow">
{levelLabel}
</p>
)}
</div>
<div>
<div className="border-t border-white/20 mb-2" />
<div className="flex items-center justify-between gap-2">
<img
src="/philpro-white-single.png"
alt="Philpro"
className="h-5 object-contain drop-shadow"
/>
<span className="text-white/75 text-[8.5px] font-bold uppercase tracking-[0.12em] text-right leading-tight drop-shadow">
Course<br />Completion
</span>
</div>
</div>
</div>
</div>
);
}
@@ -78,7 +78,7 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg flex flex-col max-h-[80vh]"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{entry && <UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />} {entry && <UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />}
@@ -110,7 +110,7 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
<Separator /> <Separator />
{/* ── Unit / lesson breakdown — fills remaining height and scrolls ── */} {/* ── Unit / lesson breakdown — fills remaining height and scrolls ── */}
<ScrollArea className="flex-1 min-h-0 pr-2"> <ScrollArea className="max-h-[50vh] pr-2">
{detailLoading && !breakdown ? ( {detailLoading && !breakdown ? (
<div className="space-y-3 py-1"> <div className="space-y-3 py-1">
{[...Array(4)].map((_, i) => ( {[...Array(4)].map((_, i) => (
@@ -0,0 +1,140 @@
import { useMemo, useRef, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { useTiers } from "@/contexts/AdminTiersContext";
import DataTable from "@/components/generic/Table/DataTable";
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
import { buildDataColumns, columnPinning } from "../../config/tiers/plans/archive/columns.config";
import { buildToolbarActions } from "../../config/tiers/plans/archive/toolbar.config";
import { buildSelectionActions } from "../../config/tiers/plans/archive/selection.config";
import { buildRowActions } from "../../config/tiers/plans/archive/rowActions.config";
import { getTimestamp } from "@/utils/timestamp.util";
export default function ArchivedTierPlansTable() {
const navigate = useNavigate();
const {
plans, planAttributes, planPagination, setPlanPagination,
loading, fetchPlans, restorePlan, bulkRestorePlans,
} = useTiers();
const [restoreTarget, setRestoreTarget] = useState(null);
const [restoreIds, setRestoreIds] = useState(null);
const tableRefsRef = useRef({
getFilters: () => [],
getSort: () => [],
resetSelection: () => {},
tableInstance: null,
});
const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
const fetchArchived = useCallback(
(params) => fetchPlans({ ...params, archived: true }),
[fetchPlans]
);
const exportConfig = useMemo(() => ({
allData: plans,
attributes: planAttributes,
filename: `${getTimestamp()}_ArchivedTierPlans`,
sheetName: "Archived Tier Plans",
}), [plans, planAttributes]);
const rowActions = buildRowActions({
navigate,
onRestore: (row) => setRestoreTarget(row),
});
const toolbarActions = buildToolbarActions({
fetchPlans: fetchArchived,
pagination: planPagination,
exportConfig,
navigate,
getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const selectionActions = buildSelectionActions({
exportConfig,
onRestore: (row) => setRestoreTarget(row),
onRestoreMany: (ids) => setRestoreIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance,
});
const columns = useMemo(
() => buildDataColumns(planAttributes, rowActions),
[planAttributes, rowActions]
);
const handleRestoreSuccess = () => {
setRestoreTarget(null);
setRestoreIds(null);
tableRefsRef.current.resetSelection?.();
fetchArchived({
page: 1,
limit: planPagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
});
};
return (
<>
<DataTable
title="Archived Plans"
data={plans}
columns={columns}
attributes={planAttributes}
pagination={planPagination}
setPagination={setPlanPagination}
loading={loading}
onFetch={fetchArchived}
onFetchFilterData={async () => []}
onRefsReady={handleRefsReady}
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
<FilterSheet
open={open}
onOpenChange={onOpenChange}
column={column}
attr={attr}
data={data}
loading={loading}
/>
)}
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="plan"
emptyMessage="No archived plans found."
/>
<RestoreDialog
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Plan"
getName={(r) => r?.label}
onRestore={(entity) => restorePlan(entity?.plan_id)}
loading={loading}
onSuccess={handleRestoreSuccess}
/>
<RestoreDialog
open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []}
entityLabel="Plan"
onRestore={({ ids }) => bulkRestorePlans(ids)}
loading={loading}
onSuccess={handleRestoreSuccess}
/>
</>
);
}
@@ -1,33 +1,69 @@
import { useEffect, useState, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { BookOpen, Check, RotateCcw } from "lucide-react"; import { ChevronsUpDown, Check, BookOpen, AlertTriangle } from "lucide-react";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandInput, CommandEmpty, CommandList, CommandItem } from "@/components/ui/command"; import { Command, CommandInput, CommandEmpty, CommandList, CommandItem } from "@/components/ui/command";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
/** /**
* CoursePicker * CoursePicker
*
* Props: * Props:
* subscription — tier slug to filter courses (e.g. "premium"). Pass null/undefined to hide. * subscription — tier slug ("premium"). Null/undefined = hidden.
* selectedIds — Set<string> of selected course_id strings * selectedIds — Set<string> of selected course_id strings (managed by parent)
* onChange — (Set<string>) => void * onChange — (Set<string>) => void
* isPreloaded — true in EditPlan (CoursePicker only mounts AFTER existing
* assignments are already in selectedIds, so no race condition).
* false in AddPlan (always bundle all on first load).
*
* Flow:
* • Shows "Bundle all?" question with two buttons.
* • "Yes, include all" → selects every course in the tier, hides picker.
* • "No, choose specific" → opens a Popover with Command+Search+Checkboxes.
*/ */
export function CoursePicker({ subscription, selectedIds, onChange }) { export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded = false }) {
const [courses, setCourses] = useState([]); const [courses, setCourses] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [search, setSearch] = useState(""); const [bundleAll, setBundleAll] = useState(true);
const [popoverOpen, setPopoverOpen] = useState(false);
const [search, setSearch] = useState("");
useEffect(() => { useEffect(() => {
if (!subscription) { setCourses([]); return; } if (!subscription) { setCourses([]); setBundleAll(true); return; }
setLoading(true); setLoading(true);
setSearch(""); setSearch("");
setBundleAll(true); // reset question to "Yes" whenever subscription changes
api.get(`/admin/courses/by-subscription?slug=${encodeURIComponent(subscription)}`) api.get(`/admin/courses/by-subscription?slug=${encodeURIComponent(subscription)}`)
.then(({ data }) => setCourses(data.data ?? [])) .then(({ data }) => {
const loaded = data.data ?? [];
setCourses(loaded);
if (!isPreloaded) {
// AddPlan: bundle all by default
setBundleAll(true);
onChange(new Set(loaded.map((c) => String(c.course_id))));
} else {
// EditPlan: CoursePicker mounts only after assignments loaded into selectedIds.
// Detect initial mode from current selectedIds vs total courses.
const size = selectedIds.size;
if (size > 0 && size < loaded.length) {
// Partial selection saved previously → specific mode
setBundleAll(false);
} else {
// All selected, or none (no courses assigned yet) → bundle all
setBundleAll(true);
onChange(new Set(loaded.map((c) => String(c.course_id))));
}
}
})
.catch(() => setCourses([])) .catch(() => setCourses([]))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [subscription]); }, [subscription]); // eslint-disable-line react-hooks/exhaustive-deps
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = search.toLowerCase(); const q = search.toLowerCase();
@@ -46,100 +82,170 @@ export function CoursePicker({ subscription, selectedIds, onChange }) {
onChange(next); onChange(next);
}; };
const checkAll = () => onChange(new Set(filtered.map((c) => String(c.course_id)))); const checkAll = () => onChange(new Set(courses.map((c) => String(c.course_id))));
const resetAll = () => onChange(new Set()); const resetAll = () => onChange(new Set());
// "Yes, include all" clicked
const handleBundleAll = () => {
setBundleAll(true);
setPopoverOpen(false);
onChange(new Set(courses.map((c) => String(c.course_id))));
};
// "No, choose specific" clicked — keeps current selection (all) so admin can uncheck specific ones
const handleSelectSpecific = () => {
setBundleAll(false);
};
const total = courses.length;
const selectedCount = selectedIds.size; const selectedCount = selectedIds.size;
if (!subscription) return null; if (!subscription) return null;
return ( return (
<div className="space-y-3"> <div className="space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<p className="text-xs text-muted-foreground">
{loading
? "Loading courses…"
: `${courses.length} course${courses.length !== 1 ? "s" : ""} in this tier${selectedCount > 0 ? ` — ${selectedCount} selected` : ""}`
}
</p>
<div className="flex items-center gap-1.5">
<Button
type="button"
size="sm"
variant="outline"
className="h-7 px-2.5 text-xs"
disabled={loading || filtered.length === 0}
onClick={checkAll}
>
<Check className="size-3 mr-1" />
Check all
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 px-2.5 text-xs"
disabled={selectedCount === 0}
onClick={resetAll}
>
<RotateCcw className="size-3 mr-1" />
Reset
</Button>
</div>
</div>
{/* ── Bundle question ──────────────────────────────────────────── */}
{loading ? ( {loading ? (
<div className="space-y-2"> <div className="flex gap-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-12 w-full rounded-lg" />)} <Skeleton className="h-8 w-36" />
</div> <Skeleton className="h-8 w-40" />
) : courses.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-5 text-sm text-muted-foreground">
<BookOpen className="size-4 shrink-0" />
No courses found with subscription <span className="font-mono font-medium ml-1">"{subscription}"</span>.
</div> </div>
) : ( ) : (
<Command className="rounded-lg border shadow-none" shouldFilter={false}> <div className="space-y-2.5">
<CommandInput <p className="text-sm">
placeholder="Search courses…" Bundle <span className="font-semibold capitalize">{subscription}</span> courses with this plan?
value={search} </p>
onValueChange={setSearch} <div className="flex gap-2">
/> <Button
<CommandList> type="button"
{filtered.length === 0 ? ( size="sm"
<CommandEmpty>No courses match your search.</CommandEmpty> variant={bundleAll ? "default" : "outline"}
) : ( onClick={handleBundleAll}
<ScrollArea className="h-64"> disabled={total === 0}
{filtered.map((course) => { >
const id = String(course.course_id); <Check className="size-3.5 mr-1.5" />
const checked = selectedIds.has(id); Yes, include all
return ( </Button>
<CommandItem <Button
key={id} type="button"
value={id} size="sm"
onSelect={() => toggle(id)} variant={!bundleAll ? "default" : "outline"}
className="flex items-start gap-3 px-3 py-2.5 cursor-pointer" onClick={handleSelectSpecific}
> disabled={total === 0}
<Checkbox >
checked={checked} No, choose specific
onCheckedChange={() => toggle(id)} </Button>
className="mt-0.5 shrink-0" </div>
onClick={(e) => e.stopPropagation()} </div>
/> )}
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium leading-snug">{course.title}</span> {/* ── Bundle all summary ───────────────────────────────────────── */}
{course.description && ( {!loading && bundleAll && total > 0 && (
<span className="text-xs text-muted-foreground line-clamp-1"> <p className="text-xs text-muted-foreground">
{course.description} All {total} <span className="capitalize">{subscription}</span> course{total !== 1 ? "s" : ""} will be included.
</span> </p>
)} )}
</div>
</CommandItem> {/* ── No courses in tier ───────────────────────────────────────── */}
); {!loading && total === 0 && (
})} <div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
</ScrollArea> <BookOpen className="size-4 shrink-0" />
)} No <span className="capitalize mx-1 font-medium">{subscription}</span> courses found. Add courses with this subscription first.
</CommandList> </div>
</Command> )}
{/* ── Specific picker (Popover) ─────────────────────────────────── */}
{!loading && !bundleAll && total > 0 && (
<div className="space-y-1.5">
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={cn(
"w-full justify-between gap-2",
selectedCount === 0 && "border-destructive text-destructive hover:border-destructive"
)}
>
{selectedCount === 0
? "No courses selected"
: `${selectedCount} of ${total} course${total !== 1 ? "s" : ""} selected`
}
<ChevronsUpDown className="size-4 opacity-50 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search courses…"
value={search}
onValueChange={setSearch}
/>
<CommandList>
{filtered.length === 0 ? (
<CommandEmpty>No courses match your search.</CommandEmpty>
) : (
<ScrollArea className="h-64">
{filtered.map((course) => {
const id = String(course.course_id);
const checked = selectedIds.has(id);
return (
<CommandItem
key={id}
value={id}
onSelect={() => toggle(id)}
className="flex items-start gap-3 px-3 py-2.5 cursor-pointer"
>
<Checkbox
checked={checked}
onCheckedChange={() => toggle(id)}
className="mt-0.5 shrink-0"
onClick={(e) => e.stopPropagation()}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium leading-snug">{course.title}</span>
{course.description && (
<span className="text-xs text-muted-foreground line-clamp-1">
{course.description}
</span>
)}
</div>
</CommandItem>
);
})}
</ScrollArea>
)}
</CommandList>
{/* Popover footer */}
<div className="border-t px-3 py-2 flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{selectedCount} of {total} selected
</span>
<div className="flex items-center gap-2">
<Checkbox
checked={selectedCount === total ? true : selectedCount > 0 ? "indeterminate" : false}
onCheckedChange={(v) => v ? checkAll() : resetAll()}
/>
<span className="text-xs text-muted-foreground select-none">
{selectedCount === total ? "Deselect all" : "Select all"}
</span>
</div>
</div>
</Command>
</PopoverContent>
</Popover>
{selectedCount === 0 && (
<p className="flex items-center gap-1.5 text-xs text-destructive">
<AlertTriangle className="size-3.5 shrink-0" />
Select at least one course to bundle with this plan.
</p>
)}
</div>
)} )}
</div> </div>
); );
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState, useCallback, useEffect } from "react"; import { useMemo, useRef, useState, useEffect } from "react";
import { useNavigate, Link } from "react-router-dom"; import { useNavigate, Link } from "react-router-dom";
import { Layers } from "lucide-react"; import { Layers } from "lucide-react";
@@ -8,7 +8,6 @@ import api from "@/utils/api.util";
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 { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
import { buildDataColumns, columnPinning } from "../../config/tiers/plans/columns.config"; import { buildDataColumns, columnPinning } from "../../config/tiers/plans/columns.config";
import { buildToolbarActions } from "../../config/tiers/plans/toolbar.config"; import { buildToolbarActions } from "../../config/tiers/plans/toolbar.config";
@@ -22,8 +21,7 @@ export default function TierPlansTable() {
const { const {
plans, planAttributes, planPagination, setPlanPagination, plans, planAttributes, planPagination, setPlanPagination,
loading, fetchPlans, deletePlan, restorePlan, loading, fetchPlans, deletePlan, bulkDeletePlans,
bulkDeletePlans, bulkRestorePlans,
} = useTiers(); } = useTiers();
const [hasAvailableCategories, setHasAvailableCategories] = useState(true); const [hasAvailableCategories, setHasAvailableCategories] = useState(true);
@@ -37,11 +35,8 @@ export default function TierPlansTable() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
const [showArchived, setShowArchived] = useState(false);
const [archiveTarget, setArchiveTarget] = useState(null); const [archiveTarget, setArchiveTarget] = useState(null);
const [restoreTarget, setRestoreTarget] = useState(null);
const [archiveIds, setArchiveIds] = useState(null); const [archiveIds, setArchiveIds] = useState(null);
const [restoreIds, setRestoreIds] = useState(null);
const tableRefsRef = useRef({ const tableRefsRef = useRef({
getFilters: () => [], getFilters: () => [],
@@ -52,30 +47,15 @@ export default function TierPlansTable() {
const handleRefsReady = (refs) => { tableRefsRef.current = refs; }; const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
const handleToggleArchived = useCallback(() => {
const next = !showArchived;
setShowArchived(next);
fetchPlans({
page: 1,
limit: planPagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(),
archived: next,
});
}, [showArchived, planPagination, fetchPlans]);
const handleSuccess = () => { const handleSuccess = () => {
setArchiveTarget(null); setArchiveTarget(null);
setRestoreTarget(null);
setArchiveIds(null); setArchiveIds(null);
setRestoreIds(null);
tableRefsRef.current.resetSelection?.(); tableRefsRef.current.resetSelection?.();
fetchPlans({ fetchPlans({
page: 1, page: 1,
limit: planPagination?.limit ?? 10, limit: planPagination?.limit ?? 10,
filters: tableRefsRef.current.getFilters(), filters: tableRefsRef.current.getFilters(),
sort: tableRefsRef.current.getSort(), sort: tableRefsRef.current.getSort(),
archived: showArchived,
}); });
}; };
@@ -88,9 +68,7 @@ export default function TierPlansTable() {
const rowActions = buildRowActions({ const rowActions = buildRowActions({
navigate, navigate,
onArchive: (row) => setArchiveTarget(row), onArchive: (row) => setArchiveTarget(row),
onRestore: (row) => setRestoreTarget(row),
showArchived,
}); });
const toolbarActions = buildToolbarActions({ const toolbarActions = buildToolbarActions({
@@ -98,9 +76,7 @@ export default function TierPlansTable() {
pagination: planPagination, pagination: planPagination,
exportConfig, exportConfig,
navigate, navigate,
showArchived,
hasAvailableCategories, hasAvailableCategories,
onToggleArchived: handleToggleArchived,
getFilters: () => tableRefsRef.current.getFilters(), getFilters: () => tableRefsRef.current.getFilters(),
getSort: () => tableRefsRef.current.getSort(), getSort: () => tableRefsRef.current.getSort(),
getTableInstance: () => tableRefsRef.current.tableInstance, getTableInstance: () => tableRefsRef.current.tableInstance,
@@ -108,10 +84,8 @@ export default function TierPlansTable() {
const selectionActions = buildSelectionActions({ const selectionActions = buildSelectionActions({
exportConfig, exportConfig,
showArchived,
onArchive: (row) => setArchiveTarget(row), onArchive: (row) => setArchiveTarget(row),
onArchiveMany: (ids) => setArchiveIds(ids), onArchiveMany: (ids) => setArchiveIds(ids),
onRestoreMany: (ids) => setRestoreIds(ids),
getTableInstance: () => tableRefsRef.current.tableInstance, getTableInstance: () => tableRefsRef.current.tableInstance,
}); });
@@ -174,18 +148,6 @@ export default function TierPlansTable() {
onSuccess={handleSuccess} onSuccess={handleSuccess}
/> />
{/* Single restore */}
<RestoreDialog
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Plan"
getName={(r) => r?.label}
onRestore={(entity) => restorePlan(entity?.plan_id)}
loading={loading}
onSuccess={handleSuccess}
/>
{/* Bulk archive */} {/* Bulk archive */}
<ArchiveDialog <ArchiveDialog
open={!!archiveIds} open={!!archiveIds}
@@ -197,16 +159,6 @@ export default function TierPlansTable() {
onSuccess={handleSuccess} onSuccess={handleSuccess}
/> />
{/* Bulk restore */}
<RestoreDialog
open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []}
entityLabel="Plan"
onRestore={({ ids }) => bulkRestorePlans(ids)}
loading={loading}
onSuccess={handleSuccess}
/>
</> </>
); );
} }
@@ -0,0 +1,56 @@
import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
import { Badge } from "@/components/ui/badge";
import { Book, BookOpenCheck, Clock } from "lucide-react";
import { formatDuration } from "@/utils/timestamp.util";
export const columnPinning = {
right: ["actions"],
left: [],
};
const cellOverrides = {
unitCount: (info) => {
const count = parseInt(info.getValue() ?? 0, 10);
return (
<div className="flex items-center gap-1.5">
<Book className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{count} {count === 1 ? "unit" : "units"}
</Badge>
</div>
);
},
lessonCount: (info) => {
const count = parseInt(info.getValue() ?? 0, 10);
return (
<div className="flex items-center gap-1.5">
<BookOpenCheck className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{count} {count === 1 ? "lesson" : "lessons"}
</Badge>
</div>
);
},
duration_seconds: (info) => {
const seconds = parseInt(info.getValue() ?? 0, 10);
return (
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
{formatDuration(seconds)}
</Badge>
</div>
);
},
};
export function buildDataColumns(attributes, rowActions) {
const visibleAttributes = attributes.filter((a) => !a.hidden);
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "Plan Actions" }),
];
}
@@ -0,0 +1,20 @@
import { Eye, RotateCcw } from "lucide-react";
export function buildRowActions({ navigate, onRestore }) {
return [
{
key: "view",
label: "View Plan",
icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/view`),
},
{
key: "restore",
label: "Restore",
icon: <RotateCcw className="h-3.5 w-3.5" />,
className: "text-emerald-600",
onClick: (row) => onRestore(row),
separator: true,
},
];
}
@@ -0,0 +1,33 @@
import { Download, ArchiveRestore } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildSelectionActions({
exportConfig,
onRestore,
onRestoreMany,
getTableInstance,
}) {
return [
{
key: "export-selected",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
onClick: (rows, table) =>
exportTableToExcel({
...exportConfig,
selectedRows: rows,
tableInstance: table ?? getTableInstance(),
}),
},
{
key: "restore-selected",
label: "Restore",
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
className: "text-emerald-600 border-emerald-600/40 hover:bg-emerald-600/10 hover:text-emerald-700",
onClick: (rows) => {
const ids = rows.map((r) => r.plan_id);
ids.length === 1 ? onRestore(rows[0]) : onRestoreMany(ids);
},
},
];
}
@@ -0,0 +1,49 @@
import { RefreshCw, Download, ArchiveRestore } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util";
export function buildToolbarActions({
fetchPlans,
pagination,
exportConfig,
navigate,
getFilters,
getSort,
getTableInstance,
}) {
return [
{
key: "refresh",
type: "button",
label: "Refresh",
icon: <RefreshCw className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => fetchPlans({
page: 1,
limit: pagination?.limit ?? 10,
filters: getFilters(),
sort: getSort(),
archived: true,
}),
},
{
key: "export",
type: "button",
label: "Export",
icon: <Download className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => exportTableToExcel({
...exportConfig,
tableInstance: getTableInstance(),
}),
},
{
key: "active-plans",
type: "button",
label: "Active Plans",
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
variant: "secondary",
className: "border border-border",
onClick: () => navigate("/admin/tiers/plans"),
},
];
}
@@ -14,6 +14,18 @@ export const columnPinning = {
}; };
// ─── Helpers ──────────────────────────────────────────────────────────────────
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
function fmtPlanDuration(days, unit) {
if (!days) return "—";
const multiplier = UNIT_TO_DAYS[unit] ?? 1;
const value = Math.round((days / multiplier) * 1000) / 1000;
const label = unit ?? "day";
return `${value} ${label}${value !== 1 ? "s" : ""}`;
}
// ─── Custom cell overrides ──────────────────────────────────────────────────── // ─── Custom cell overrides ────────────────────────────────────────────────────
const cellOverrides = { const cellOverrides = {
unitCount: (info) => { unitCount: (info) => {
@@ -38,6 +50,16 @@ const cellOverrides = {
</div> </div>
); );
}, },
duration_days: (info) => {
const days = info.getValue();
const unit = info.row.original.duration_unit;
return (
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm tabular-nums">{fmtPlanDuration(days, unit)}</span>
</div>
);
},
duration_seconds: (info) => { duration_seconds: (info) => {
const seconds = parseInt(info.getValue() ?? 0, 10); const seconds = parseInt(info.getValue() ?? 0, 10);
@@ -1,6 +1,6 @@
import { Eye, Pencil, Archive, RotateCcw, ShelvingUnit } from "lucide-react"; import { Eye, Pencil, Archive, ShelvingUnit, CreditCard, Globe } from "lucide-react";
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) { export function buildRowActions({ navigate, onArchive }) {
return [ return [
{ {
key: "view", key: "view",
@@ -13,15 +13,22 @@ export function buildRowActions({ navigate, onArchive, onRestore, showArchived }
label: "Edit Plan", label: "Edit Plan",
icon: <Pencil className="h-3.5 w-3.5" />, icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/edit`), onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/edit`),
hidden: () => showArchived,
}, },
{ {
key: "view_units", key: "payment_policy",
label: "View Payments", label: "Payment Policy",
icon: <ShelvingUnit className="h-3.5 w-3.5" />, icon: <CreditCard className="h-3.5 w-3.5" />,
className: "text-blue-700 hover:text-blue-600",
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/payment-policy`),
separator: true,
},
{
key: "view_payments",
label: "View Payments",
icon: <ShelvingUnit className="h-3.5 w-3.5" />,
className: "text-sky-700 hover:text-sky-600", className: "text-sky-700 hover:text-sky-600",
onClick: (row) => navigate(`/admin/tiers/payments?plan_id=${row.plan_id}`), onClick: (row) => navigate(`/admin/tiers/payments?plan_id=${row.plan_id}`),
separator: true
}, },
{ {
key: "archive", key: "archive",
@@ -30,15 +37,6 @@ export function buildRowActions({ navigate, onArchive, onRestore, showArchived }
className: "text-destructive", className: "text-destructive",
onClick: (row) => onArchive(row), onClick: (row) => onArchive(row),
separator: true, separator: true,
hidden: () => showArchived,
},
{
key: "restore",
label: "Restore",
icon: <RotateCcw className="h-3.5 w-3.5" />,
className: "text-emerald-600",
onClick: (row) => onRestore(row),
hidden: () => !showArchived,
}, },
]; ];
} }
@@ -1,12 +1,10 @@
import { Download, Archive, RotateCcw } from "lucide-react"; import { Download, Archive } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util"; import { exportTableToExcel } from "@/utils/excel.util";
export function buildSelectionActions({ export function buildSelectionActions({
exportConfig, exportConfig,
showArchived,
onArchive, onArchive,
onArchiveMany, onArchiveMany,
onRestoreMany,
getTableInstance, getTableInstance,
}) { }) {
return [ return [
@@ -21,7 +19,7 @@ export function buildSelectionActions({
tableInstance: table ?? getTableInstance(), tableInstance: table ?? getTableInstance(),
}), }),
}, },
!showArchived && { {
key: "archive-selected", key: "archive-selected",
label: "Archive", label: "Archive",
icon: <Archive className="h-3.5 w-3.5" />, icon: <Archive className="h-3.5 w-3.5" />,
@@ -31,15 +29,5 @@ export function buildSelectionActions({
ids.length === 1 ? onArchive(rows[0]) : onArchiveMany(ids); ids.length === 1 ? onArchive(rows[0]) : onArchiveMany(ids);
}, },
}, },
showArchived && { ];
key: "restore-selected",
label: "Restore",
icon: <RotateCcw className="h-3.5 w-3.5" />,
className: "text-emerald-600 border-emerald-600/40 hover:bg-emerald-600/10 hover:text-emerald-600",
onClick: (rows) => {
const ids = rows.map((r) => r.plan_id);
onRestoreMany(ids);
},
},
].filter(Boolean);
} }
@@ -1,4 +1,4 @@
import { Plus, RefreshCw, Download, Archive, Layers } from "lucide-react"; import { Plus, RefreshCw, Download, Archive, Layers, Globe } from "lucide-react";
import { exportTableToExcel } from "@/utils/excel.util"; import { exportTableToExcel } from "@/utils/excel.util";
export function buildToolbarActions({ export function buildToolbarActions({
@@ -6,9 +6,7 @@ export function buildToolbarActions({
pagination, pagination,
exportConfig, exportConfig,
navigate, navigate,
showArchived,
hasAvailableCategories, hasAvailableCategories,
onToggleArchived,
getFilters, getFilters,
getSort, getSort,
getTableInstance, getTableInstance,
@@ -21,11 +19,10 @@ export function buildToolbarActions({
icon: <RefreshCw className="h-3.5 w-3.5" />, icon: <RefreshCw className="h-3.5 w-3.5" />,
variant: "outline", variant: "outline",
onClick: () => fetchPlans({ onClick: () => fetchPlans({
page: 1, page: 1,
limit: pagination?.limit ?? 10, limit: pagination?.limit ?? 10,
filters: getFilters(), filters: getFilters(),
sort: getSort(), sort: getSort(),
archived: showArchived,
}), }),
}, },
{ {
@@ -47,23 +44,31 @@ export function buildToolbarActions({
variant: "outline", variant: "outline",
onClick: () => navigate("/admin/tiers/categories"), onClick: () => navigate("/admin/tiers/categories"),
}, },
{
key: "localized-prices",
type: "button",
label: "Localized Prices",
icon: <Globe className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => navigate("/admin/tiers/prices"),
},
{ {
key: "create", key: "create",
type: "button", type: "button",
label: "New Plan", label: "New Plan",
icon: <Plus className="h-3.5 w-3.5" />, icon: <Plus className="h-3.5 w-3.5" />,
variant: "default", variant: "default",
hidden: showArchived || !hasAvailableCategories, hidden: !hasAvailableCategories,
onClick: () => navigate("/admin/tiers/plans/add"), onClick: () => navigate("/admin/tiers/plans/add"),
}, },
{ {
key: "toggle-archived", key: "archived-plans",
type: "button", type: "button",
icon: <Archive className="h-3.5 w-3.5" />, icon: <Archive className="h-3.5 w-3.5" />,
label: showArchived ? "Active Plans" : "Archived Plans", label: "Archived Plans",
variant: "secondary", variant: "secondary",
className: "border border-border", className: "border border-border",
onClick: onToggleArchived, onClick: () => navigate("/admin/tiers/plans/archived"),
}, },
]; ];
} }
+3 -2
View File
@@ -50,8 +50,9 @@ const AdminLayout = () => {
<div ref={headerRef} className={cn('fixed top-0 z-50 w-full bg-background border-b')} > <div ref={headerRef} className={cn('fixed top-0 z-50 w-full bg-background border-b')} >
<div className="w-full flex items-center justify-between xs:px-4 lg:px-5 py-3"> <div className="w-full flex items-center justify-between xs:px-4 lg:px-5 py-3">
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="xs:hidden sm:block w-40 cursor-pointer" onClick={() => navigate(`/admin`)}> <div className="w-40 cursor-pointer" onClick={() => navigate("/")}>
<img src="/philpro-white.png" alt="" className="object-cover" /> <img src="/philpro-white.png" alt="Philproperties" className="object-cover dark:hidden" />
<img src="/philpro-dark.png" alt="Philproperties" className="object-cover hidden dark:block" />
</div> </div>
<div> <div>
<svg <svg
@@ -147,7 +147,7 @@ export default function AddAdvertisement() {
}; };
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
<div className="flex flex-col gap-2 my-6 w-full"> <div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} /> <AppBreadcrumb items={breadcrumbItems} />
@@ -51,7 +51,7 @@ export default function AdvertisementList() {
} }
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6 w-full"> <div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
@@ -189,7 +189,7 @@ export default function EditAdvertisement() {
}; };
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
<div className="flex flex-col gap-2 my-6 w-full"> <div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} /> <AppBreadcrumb items={breadcrumbItems} />
@@ -64,7 +64,7 @@ export default function ViewAdvertisement() {
if (loading && !advertisement) { if (loading && !advertisement) {
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<div className="flex items-center justify-center py-32"> <div className="flex items-center justify-center py-32">
<Spinner className="size-6" /> <Spinner className="size-6" />
</div> </div>
@@ -74,7 +74,7 @@ export default function ViewAdvertisement() {
if (!advertisement) { if (!advertisement) {
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6 w-full"> <div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} /> <AppBreadcrumb items={breadcrumbItems} />
@@ -93,7 +93,7 @@ export default function ViewAdvertisement() {
const ctas = Array.isArray(advertisement.ctas) ? advertisement.ctas : []; const ctas = Array.isArray(advertisement.ctas) ? advertisement.ctas : [];
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
<div className="flex flex-col gap-2 my-6 w-full"> <div className="flex flex-col gap-2 my-6 w-full">
<AppBreadcrumb items={breadcrumbItems} /> <AppBreadcrumb items={breadcrumbItems} />
@@ -11,7 +11,7 @@ export default function ArchivedAssetList() {
]; ];
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6"> <div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
+1 -1
View File
@@ -10,7 +10,7 @@ export default function AssetList() {
] ]
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6 "> <div className="flex flex-col gap-2 my-6 ">
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
+252 -15
View File
@@ -1,20 +1,21 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm, useFieldArray } from "react-hook-form"; import { useForm, useFieldArray, useWatch } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, House, Plus, Trash2 } from "lucide-react"; import { ArrowLeft, Plus, Trash2, BadgeCheck, Trophy, Check, ChevronsUpDown, X, ImagePlus, Palette } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
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 { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -22,6 +23,24 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { ScrollArea } from "@/components/ui/scroll-area";
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
import { ACHIEVEMENT_REGISTRY } from "@/utils/achievements.data";
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
// ─── Schema ─────────────────────────────────────────────────────────────────── // ─── Schema ───────────────────────────────────────────────────────────────────
@@ -33,6 +52,7 @@ const schema = z.object({
level: z.enum(["beginner", "intermediate", "advanced"]).optional(), level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
subscription: z.string().min(1, "Subscription is required.").default("free"), subscription: z.string().min(1, "Subscription is required.").default("free"),
objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]), objectives: z.array(z.object({ text: z.string().min(1, "Objective cannot be empty.") })).default([]),
achievement_keys: z.array(z.string()).max(3).default([]),
}); });
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -70,12 +90,18 @@ export default function AddCourse() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
// ─── Badge config state ─────────────────────────────────────────────────
const [badgeColor, setBadgeColor] = useState("purple");
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
const [badgeAssetId, setBadgeAssetId] = useState(null);
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
const [achOpen, setAchOpen] = useState(false);
const { const {
register, register,
handleSubmit, handleSubmit,
control, control,
setValue, setValue,
watch,
formState: { errors }, formState: { errors },
} = useForm({ } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
@@ -87,18 +113,35 @@ export default function AddCourse() {
level: "beginner", level: "beginner",
subscription: "free", subscription: "free",
objectives: [], objectives: [],
achievement_keys: [],
}, },
}); });
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } = const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
useFieldArray({ control, name: "objectives" }); useFieldArray({ control, name: "objectives" });
const currentAchKeys = useWatch({ control, name: "achievement_keys" });
const watchedTitle = useWatch({ control, name: "title" });
const watchedLevel = useWatch({ control, name: "level" });
const watchedSubscr = useWatch({ control, name: "subscription" });
const toggleAchievement = (key) => {
if (currentAchKeys.includes(key)) {
setValue("achievement_keys", currentAchKeys.filter((k) => k !== key), { shouldDirty: true });
} else if (currentAchKeys.length < 3) {
setValue("achievement_keys", [...currentAchKeys, key], { shouldDirty: true });
}
};
const onSubmit = async (values) => { const onSubmit = async (values) => {
const payload = { const payload = {
...values, ...values,
objectives: values.objectives.map((o) => o.text), objectives: values.objectives.map((o) => o.text),
level: values.level || null, level: values.level || null,
course_code: values.course_code || null, course_code: values.course_code || null,
badge_color: badgeColor,
badge_asset_id: badgeAssetId ?? null,
badge_image_url: badgeImageUrl ?? null,
createdBy: user?.user_id ?? null, createdBy: user?.user_id ?? null,
}; };
@@ -118,8 +161,8 @@ export default function AddCourse() {
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div> <div>
<h1 className="text-xl font-semibold">Course Details</h1> <h1 className="text-xl font-semibold">Add Course</h1>
<p className="text-sm text-muted-foreground">View course information.</p> <p className="text-sm text-muted-foreground">Create a new training course.</p>
</div> </div>
</div> </div>
@@ -127,7 +170,6 @@ export default function AddCourse() {
{/* ── Basic Info ── */} {/* ── Basic Info ── */}
<SectionCard title="Basic Information"> <SectionCard title="Basic Information">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label> <Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
<Input id="title" placeholder="e.g. Introduction to Real Estate" {...register("title")} /> <Input id="title" placeholder="e.g. Introduction to Real Estate" {...register("title")} />
@@ -151,18 +193,15 @@ export default function AddCourse() {
<FieldError message={errors.order_index?.message} /> <FieldError message={errors.order_index?.message} />
</div> </div>
</div> </div>
</SectionCard> </SectionCard>
{/* ── Settings ── */} {/* ── Settings ── */}
<SectionCard title="Settings"> <SectionCard title="Settings">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Level</Label> <Label>Level</Label>
<Select <Select
value={watch("level") ?? ""} value={watchedLevel ?? ""}
onValueChange={(val) => setValue("level", val, { shouldDirty: true })} onValueChange={(val) => setValue("level", val, { shouldDirty: true })}
> >
<SelectTrigger> <SelectTrigger>
@@ -180,7 +219,7 @@ export default function AddCourse() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Subscription</Label> <Label>Subscription</Label>
<Select <Select
value={watch("subscription") ?? "free"} value={watchedSubscr ?? "free"}
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })} onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
> >
<SelectTrigger> <SelectTrigger>
@@ -196,9 +235,7 @@ export default function AddCourse() {
</Select> </Select>
<FieldError message={errors.subscription?.message} /> <FieldError message={errors.subscription?.message} />
</div> </div>
</div> </div>
</SectionCard> </SectionCard>
{/* ── Objectives ── */} {/* ── Objectives ── */}
@@ -241,6 +278,195 @@ export default function AddCourse() {
</div> </div>
</SectionCard> </SectionCard>
{/* ── Rewards ── */}
<SectionCard
title="Rewards"
description="Badge and achievements awarded to learners who complete this course."
>
{/* ── Completion Badge ── */}
<div>
<p className="text-xs font-medium mb-3 text-muted-foreground uppercase tracking-wide">Completion Badge</p>
<div className="flex items-start gap-4">
<CourseBadge
title={watchedTitle || "Course Title"}
level={watchedLevel}
color={badgeColor}
imageUrl={badgeImageUrl}
/>
<div className="flex-1 flex flex-col gap-3">
{/* Metadata */}
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Label:</span> Course Completion
</div>
<div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Trigger:</span> Pass course assessment
</div>
<div className="flex items-center gap-1.5 pb-0.5">
<span className="font-medium text-foreground">Type:</span> Milestone achievement
</div>
<Badge className="self-start bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
<BadgeCheck className="size-3" /> Mandatory
</Badge>
</div>
{/* Color picker */}
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<Palette className="h-3 w-3" /> Color
</p>
<div className="flex flex-wrap gap-1.5">
{TIER_COLOR_OPTIONS.map((opt) => (
<button
key={opt.key}
type="button"
title={opt.label}
onClick={() => setBadgeColor(opt.key)}
className={[
"w-5 h-5 rounded-full border-2 transition-all",
badgeColor === opt.key
? "border-foreground scale-110 shadow-sm"
: "border-transparent hover:border-muted-foreground/50",
].join(" ")}
style={{ backgroundColor: opt.swatch }}
/>
))}
</div>
</div>
{/* Image picker */}
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<ImagePlus className="h-3 w-3" /> Image <span className="normal-case font-normal">(optional)</span>
</p>
<div className="flex items-center gap-2">
{badgeImageUrl && (
<div className="w-8 h-8 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
<img src={badgeImageUrl} alt="" className="w-6 h-6 object-contain" />
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAssetPickerOpen(true)}
className="h-7 text-xs"
>
<ImagePlus className="h-3 w-3 mr-1" />
{badgeImageUrl ? "Change" : "Pick from assets"}
</Button>
{badgeImageUrl && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => { setBadgeImageUrl(null); setBadgeAssetId(null); }}
>
<X className="h-3 w-3 mr-1" /> Remove
</Button>
)}
</div>
</div>
</div>
</div>
</div>
{/* ── Achievements ── */}
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Achievements</p>
<span className="text-[10px] text-muted-foreground">{currentAchKeys.length}/3 selected</span>
</div>
{/* Selected badges */}
{currentAchKeys.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{currentAchKeys.map((key) => {
const ach = ACHIEVEMENT_REGISTRY.find((a) => a.key === key);
return (
<Badge key={key} variant="secondary" className="gap-1 pr-1">
{ach?.label ?? key}
<button
type="button"
className="ml-0.5 rounded-full hover:bg-muted"
onClick={() => toggleAchievement(key)}
>
<X className="h-3 w-3" />
</button>
</Badge>
);
})}
</div>
)}
{/* Popover picker */}
<Popover open={achOpen} onOpenChange={setAchOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-between"
disabled={currentAchKeys.length >= 3}
>
<span className="flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5" />
{currentAchKeys.length > 0
? `${currentAchKeys.length} selected — add more`
: "Select achievements"}
</span>
<ChevronsUpDown className="h-3 w-3 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput placeholder="Search achievements…" />
<CommandList className="max-h-none">
<CommandEmpty>No achievements found.</CommandEmpty>
<CommandGroup>
<ScrollArea className="h-64">
{ACHIEVEMENT_REGISTRY.map((ach) => {
const checked = currentAchKeys.includes(ach.key);
const disabled = !checked && currentAchKeys.length >= 3;
return (
<CommandItem
key={ach.key}
value={ach.label}
disabled={disabled}
onSelect={() => !disabled && toggleAchievement(ach.key)}
className="gap-2 items-start py-2"
>
<Checkbox
checked={checked}
className="pointer-events-none mt-0.5 shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-medium">{ach.label}</span>
<Badge variant="outline" className="text-[9px] px-1 py-0 gap-0.5 capitalize">
{ach.type === "badge"
? <Trophy className="h-2.5 w-2.5" />
: <BadgeCheck className="h-2.5 w-2.5" />
}
{ach.type}
</Badge>
</div>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5">{ach.description}</p>
</div>
{checked && <Check className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />}
</CommandItem>
);
})}
</ScrollArea>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</SectionCard>
{/* ── Actions ── */} {/* ── Actions ── */}
<div className="flex justify-end gap-3 pt-1"> <div className="flex justify-end gap-3 pt-1">
<Button <Button
@@ -260,6 +486,17 @@ export default function AddCourse() {
</form> </form>
</div> </div>
</div> </div>
{/* Asset picker for badge image */}
<AssetPickerSheet
open={assetPickerOpen}
onOpenChange={setAssetPickerOpen}
fileType="image"
onSelect={(asset, resolvedUrl) => {
setBadgeImageUrl(resolvedUrl ?? null);
setBadgeAssetId(asset.asset_id);
}}
/>
</section> </section>
); );
} }
@@ -11,7 +11,7 @@ export default function ArchivedCourseList() {
]; ];
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta title="Archived Courses - STARR" description="View archived training courses." /> <PageMeta title="Archived Courses - STARR" description="View archived training courses." />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6"> <div className="flex flex-col gap-2 my-6">
@@ -10,7 +10,7 @@ export default function CourseList() {
]; ];
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta title="Courses - STARR" description="Browse and manage your training courses." /> <PageMeta title="Courses - STARR" description="Browse and manage your training courses." />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6"> <div className="flex flex-col gap-2 my-6">
+302 -47
View File
@@ -1,11 +1,15 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useForm, useFieldArray } from "react-hook-form"; import { useForm, useFieldArray, useWatch } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, Plus, Trash2, Save, BadgeCheck, GripVertical, Tag, ChevronsUpDown, Check, X } from "lucide-react"; import { ArrowLeft, Plus, Trash2, Save, BadgeCheck, GripVertical, Tag, ChevronsUpDown, Check, X, Trophy, ImagePlus, Palette } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
import { ACHIEVEMENT_REGISTRY } from "@/utils/achievements.data";
import { TIER_COLOR_OPTIONS } from "@/utils/tierColors";
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import CourseInstructorPicker from "@/modules/admin/components/courses/CourseInstructorPicker"; import CourseInstructorPicker from "@/modules/admin/components/courses/CourseInstructorPicker";
import { useCategories } from "@/contexts/AdminCategoriesContext"; import { useCategories } from "@/contexts/AdminCategoriesContext";
@@ -39,6 +43,7 @@ import {
CommandItem, CommandItem,
CommandList, CommandList,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { ScrollArea } from "@/components/ui/scroll-area";
// ─── Schema ─────────────────────────────────────────────────────────────────── // ─── Schema ───────────────────────────────────────────────────────────────────
@@ -56,21 +61,6 @@ const schema = z.object({
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
const CertBadgeIcon = ({ className }) => (
<svg className={className} width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Prism logo">
<defs>
<linearGradient id="prism-ec" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#8EA2F6"/>
<stop offset="1" stopColor="#5061E6"/>
</linearGradient>
</defs>
<g transform="rotate(45 60 60)">
<rect x="24" y="24" width="72" height="72" rx="16" fill="url(#prism-ec)"/>
<rect x="46" y="46" width="28" height="28" rx="6" fill="#FFFFFF"/>
</g>
</svg>
);
function FieldError({ message }) { function FieldError({ message }) {
if (!message) return null; if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>; return <p className="text-xs text-destructive mt-1">{message}</p>;
@@ -97,7 +87,7 @@ function SectionCard({ title, description, children }) {
export default function EditCourse() { export default function EditCourse() {
const navigate = useNavigate(); const navigate = useNavigate();
const { courseId } = useParams(); const { courseId } = useParams();
const { fetchCourse, updateCourse, fetchCourseProduct, saveCourseProduct, removeCourseProduct, fetchCourseCategories, syncCourseCategories, fetchInstructors, syncInstructors, loading, course } = useCourses(); const { fetchCourse, updateCourse, fetchCourseProduct, saveCourseProduct, removeCourseProduct, fetchCourseCategories, syncCourseCategories, fetchInstructors, syncInstructors, fetchCourseAchievements, syncCourseAchievements, loading, course } = useCourses();
const { categories: allCategories, fetchCategories } = useCategories(); const { categories: allCategories, fetchCategories } = useCategories();
const { user } = useAuth(); const { user } = useAuth();
@@ -120,6 +110,20 @@ export default function EditCourse() {
const [instructorsDirty, setInstructorsDirty] = useState(false); const [instructorsDirty, setInstructorsDirty] = useState(false);
const [instructorsLoading, setInstructorsLoading] = useState(false); const [instructorsLoading, setInstructorsLoading] = useState(false);
// ─── Achievements state ───────────────────────────────────────────────────
const [selectedAchievementKeys, setSelectedAchievementKeys] = useState([]);
const [achievementsDirty, setAchievementsDirty] = useState(false);
const [achievementsLoading, setAchievementsLoading] = useState(false);
const [achOpen, setAchOpen] = useState(false);
// ─── Badge config state ───────────────────────────────────────────────────
const [badgeColor, setBadgeColor] = useState("purple");
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
const [badgeAssetId, setBadgeAssetId] = useState(null);
const [badgeDirty, setBadgeDirty] = useState(false);
const [badgeLoading, setBadgeLoading] = useState(false);
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
// ─── Product state ──────────────────────────────────────────────────────── // ─── Product state ────────────────────────────────────────────────────────
const [product, setProduct] = useState(null); const [product, setProduct] = useState(null);
const [productDirty, setProductDirty] = useState(false); const [productDirty, setProductDirty] = useState(false);
@@ -134,7 +138,6 @@ export default function EditCourse() {
reset, reset,
control, control,
setValue, setValue,
watch,
formState: { errors, isDirty }, formState: { errors, isDirty },
} = useForm({ } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
@@ -155,6 +158,12 @@ export default function EditCourse() {
remove: removeObjective, remove: removeObjective,
} = useFieldArray({ control, name: "objectives" }); } = useFieldArray({ control, name: "objectives" });
// useWatch is the correct hook for reading form values in render — avoids
// synchronous re-subscription loops that watch() can trigger in RHF 7.75+.
const watchedTitle = useWatch({ control, name: "title" });
const watchedLevel = useWatch({ control, name: "level" });
const watchedSubscription = useWatch({ control, name: "subscription" });
// ─── Load existing course data ──────────────────────────────────────────── // ─── Load existing course data ────────────────────────────────────────────
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@@ -174,15 +183,33 @@ export default function EditCourse() {
text: o.text ?? "", text: o.text ?? "",
})), })),
}); });
setBadgeColor(c.badge_color ?? "purple");
setBadgeAssetId(c.badge_asset_id ?? null);
// If a badge asset was saved, issue a fresh stream token so the preview
// works for private S3 images (stored file_url is a private CDN key).
if (c.badge_asset_id) {
api.post("/admin/media/token", { asset_id: c.badge_asset_id })
.then(({ data }) => {
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
const thumbUrl = data.data?.thumbnail_url;
const token = data.data?.token;
setBadgeImageUrl(thumbUrl ?? (token ? `${STREAM_BASE}/${token}` : null));
})
.catch(() => setBadgeImageUrl(c.badge_image_url ?? null));
} else {
setBadgeImageUrl(c.badge_image_url ?? null);
}
})(); })();
// Load categories, product, and instructors in parallel // Load categories, product, instructors, and achievements in parallel
(async () => { (async () => {
await fetchCategories(); await fetchCategories();
const [cats, prod, insts] = await Promise.all([ const [cats, prod, insts, achKeys] = await Promise.all([
fetchCourseCategories(courseId), fetchCourseCategories(courseId),
fetchCourseProduct(courseId), fetchCourseProduct(courseId),
fetchInstructors(courseId), fetchInstructors(courseId),
fetchCourseAchievements(courseId),
]); ]);
setSelectedCategoryIds((cats ?? []).map((c) => String(c.id))); setSelectedCategoryIds((cats ?? []).map((c) => String(c.id)));
if (prod) { if (prod) {
@@ -203,6 +230,7 @@ export default function EditCourse() {
order_index: i.order_index ?? 0, order_index: i.order_index ?? 0,
})) }))
); );
setSelectedAchievementKeys(achKeys ?? []);
})(); })();
}, [courseId]); }, [courseId]);
@@ -281,6 +309,36 @@ export default function EditCourse() {
setInstructorsLoading(false); setInstructorsLoading(false);
}; };
// ─── Badge handlers ───────────────────────────────────────────────────────
const handleSaveBadge = async () => {
setBadgeLoading(true);
await updateCourse(courseId, {
badge_color: badgeColor,
badge_asset_id: badgeAssetId,
badge_image_url: badgeImageUrl,
updatedBy: user?.user_id ?? null,
});
setBadgeDirty(false);
setBadgeLoading(false);
};
// ─── Achievement handlers ─────────────────────────────────────────────────
const toggleAchievement = (key) => {
setSelectedAchievementKeys((prev) => {
if (prev.includes(key)) return prev.filter((k) => k !== key);
if (prev.length >= 3) return prev;
return [...prev, key];
});
setAchievementsDirty(true);
};
const handleSaveAchievements = async () => {
setAchievementsLoading(true);
await syncCourseAchievements(courseId, selectedAchievementKeys);
setAchievementsDirty(false);
setAchievementsLoading(false);
};
const onSubmit = async (values) => { const onSubmit = async (values) => {
if (!isDirty) return navigate(-1); if (!isDirty) return navigate(-1);
@@ -381,7 +439,7 @@ export default function EditCourse() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Level</Label> <Label>Level</Label>
<Select <Select
value={watch("level") ?? ""} value={watchedLevel ?? ""}
onValueChange={(val) => onValueChange={(val) =>
setValue("level", val, { shouldDirty: true }) setValue("level", val, { shouldDirty: true })
} }
@@ -401,7 +459,7 @@ export default function EditCourse() {
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Subscription</Label> <Label>Subscription</Label>
<Select <Select
value={watch("subscription") ?? "free"} value={watchedSubscription ?? "free"}
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })} onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
> >
<SelectTrigger> <SelectTrigger>
@@ -701,40 +759,237 @@ export default function EditCourse() {
</div> </div>
</SectionCard> </SectionCard>
{/* ── Certificate of Completion ── */} {/* ── Rewards ── */}
<SectionCard <SectionCard
title="Certificate of Completion" title="Rewards"
description="Automatically awarded to learners who pass this course's assessment. Mandatory for all courses." description="Badge and achievements awarded to learners who complete this course."
> >
<div className="flex items-start gap-4"> {/* ── Completion Badge ── */}
<CertBadgeIcon className="size-20 shrink-0" /> <div>
<div className="flex flex-col gap-2 pt-1"> <p className="text-xs font-medium mb-3 text-muted-foreground uppercase tracking-wide">Completion Badge</p>
<div className="flex items-center gap-2"> <div className="flex items-start gap-4">
<span className="text-sm font-semibold">Certificate of Completion</span> <CourseBadge
<Badge className="bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs"> title={watchedTitle || "Course Title"}
<BadgeCheck className="size-3" /> Mandatory level={watchedLevel}
</Badge> color={badgeColor}
</div> imageUrl={badgeImageUrl}
<p className="text-xs text-muted-foreground leading-relaxed"> />
This badge is issued automatically when a learner passes the course assessment. <div className="flex-1 flex flex-col gap-3">
It appears on their profile under <strong>Certificates</strong> and in the course {/* Metadata */}
content listing for all enrolled users. <div className="flex flex-col gap-1 text-xs text-muted-foreground">
</p> <div className="flex items-center gap-1.5">
<div className="flex flex-col gap-0.5 mt-1"> <span className="font-medium text-foreground">Label:</span> Course Completion
<div className="flex items-center gap-2 text-xs text-muted-foreground"> </div>
<span className="font-medium text-foreground">Label:</span> Certificate of Completion <div className="flex items-center gap-1.5">
<span className="font-medium text-foreground">Trigger:</span> Pass course assessment
</div>
<div className="flex items-center gap-1.5 pb-0.5">
<span className="font-medium text-foreground">Type:</span> Milestone achievement
</div>
<Badge className="self-start bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
<BadgeCheck className="size-3" /> Mandatory
</Badge>
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">Trigger:</span> Pass course assessment {/* Color picker */}
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<Palette className="h-3 w-3" /> Color
</p>
<div className="flex flex-wrap gap-1.5">
{TIER_COLOR_OPTIONS.map((opt) => (
<button
key={opt.key}
type="button"
title={opt.label}
onClick={() => { setBadgeColor(opt.key); setBadgeDirty(true); }}
className={[
"w-5 h-5 rounded-full border-2 transition-all",
badgeColor === opt.key
? "border-foreground scale-110 shadow-sm"
: "border-transparent hover:border-muted-foreground/50",
].join(" ")}
style={{ backgroundColor: opt.swatch }}
/>
))}
</div>
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">Type:</span> Milestone achievement {/* Image picker */}
<div className="space-y-1.5">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1">
<ImagePlus className="h-3 w-3" /> Image <span className="normal-case font-normal">(optional)</span>
</p>
<div className="flex items-center gap-2">
{badgeImageUrl && (
<div className="w-8 h-8 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
<img src={badgeImageUrl} alt="" className="w-6 h-6 object-contain" />
</div>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAssetPickerOpen(true)}
className="h-7 text-xs"
>
<ImagePlus className="h-3 w-3 mr-1" />
{badgeImageUrl ? "Change" : "Pick from assets"}
</Button>
{badgeImageUrl && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
onClick={() => { setBadgeImageUrl(null); setBadgeAssetId(null); setBadgeDirty(true); }}
>
<X className="h-3 w-3 mr-1" /> Remove
</Button>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
{/* Badge save */}
<div className="flex justify-end pt-3 mt-1 border-t">
<Button
type="button"
size="sm"
disabled={!badgeDirty || badgeLoading}
onClick={handleSaveBadge}
>
{badgeLoading && <Spinner className="h-3 w-3 mr-1.5" />}
<Save className="h-3 w-3 mr-1.5" />
Save Badge
</Button>
</div>
</div>
{/* ── Achievements ── */}
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Achievements</p>
<span className="text-[10px] text-muted-foreground">{selectedAchievementKeys.length}/3 selected</span>
</div>
{/* Selected badges */}
{selectedAchievementKeys.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{selectedAchievementKeys.map((key) => {
const ach = ACHIEVEMENT_REGISTRY.find((a) => a.key === key);
return (
<Badge key={key} variant="secondary" className="gap-1 pr-1">
{ach?.label ?? key}
<button
type="button"
className="ml-0.5 rounded-full hover:bg-muted"
onClick={() => toggleAchievement(key)}
>
<X className="h-3 w-3" />
</button>
</Badge>
);
})}
</div>
)}
{/* Popover picker */}
<Popover open={achOpen} onOpenChange={setAchOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-between"
disabled={selectedAchievementKeys.length >= 3}
>
<span className="flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5" />
{selectedAchievementKeys.length > 0
? `${selectedAchievementKeys.length} selected — add more`
: "Select achievements"}
</span>
<ChevronsUpDown className="h-3 w-3 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput placeholder="Search achievements…" />
<CommandList className="max-h-none">
<CommandEmpty>No achievements found.</CommandEmpty>
<CommandGroup>
<ScrollArea className="h-64">
{ACHIEVEMENT_REGISTRY.map((ach) => {
const checked = selectedAchievementKeys.includes(ach.key);
const disabled = !checked && selectedAchievementKeys.length >= 3;
return (
<CommandItem
key={ach.key}
value={ach.label}
disabled={disabled}
onSelect={() => !disabled && toggleAchievement(ach.key)}
className="gap-2 items-start py-2"
>
<Checkbox
checked={checked}
className="pointer-events-none mt-0.5 shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-medium">{ach.label}</span>
<Badge variant="outline" className="text-[9px] px-1 py-0 gap-0.5 capitalize">
{ach.type === "badge"
? <Trophy className="h-2.5 w-2.5" />
: <BadgeCheck className="h-2.5 w-2.5" />
}
{ach.type}
</Badge>
</div>
<p className="text-[10px] text-muted-foreground leading-snug mt-0.5">{ach.description}</p>
</div>
{checked && <Check className="h-3.5 w-3.5 text-primary shrink-0 mt-0.5" />}
</CommandItem>
);
})}
</ScrollArea>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div className="flex justify-end pt-3 mt-1 border-t">
<Button
type="button"
size="sm"
disabled={!achievementsDirty || achievementsLoading}
onClick={handleSaveAchievements}
>
{achievementsLoading && <Spinner className="h-3 w-3 mr-1.5" />}
<Save className="h-3 w-3 mr-1.5" />
Save Achievements
</Button>
</div>
</div> </div>
</SectionCard> </SectionCard>
{/* Asset picker for badge image */}
<AssetPickerSheet
open={assetPickerOpen}
onOpenChange={setAssetPickerOpen}
fileType="image"
onSelect={(asset, resolvedUrl) => {
// resolvedUrl is the already-authenticated stream/presigned URL
// from AssetPickerSheet — use it directly so private S3 images
// display in the badge preview instead of the inaccessible file_url.
setBadgeImageUrl(resolvedUrl ?? null);
setBadgeAssetId(asset.asset_id);
setBadgeDirty(true);
}}
/>
{/* ── Actions ── */} {/* ── Actions ── */}
<div className="flex justify-end gap-3 pt-1"> <div className="flex justify-end gap-3 pt-1">
<Button <Button
+139 -9
View File
@@ -1,15 +1,18 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { import {
ArrowLeft, House, Pencil, Clock, BookOpen, Layers, ArrowLeft, Pencil, Clock, BookOpen, Layers,
BadgeCheck, Tag, Star, Lock, ListChecks, BarChart2, BadgeCheck, Tag, Star, Lock, ListChecks, BarChart2,
Trophy, Users, Award,
} from "lucide-react"; } from "lucide-react";
import { useCourses } from "@/contexts/AdminCoursesContext"; import { useCourses } from "@/contexts/AdminCoursesContext";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList"; import CourseReadingProgressList from "../../components/courses/CourseReadingProgressList";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
import { ACHIEVEMENT_REGISTRY } from "@/utils/achievements.data";
import api from "@/utils/api.util";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -71,10 +74,43 @@ export default function ViewCourse() {
const { fetchCourse, course, loading } = useCourses(); const { fetchCourse, course, loading } = useCourses();
const { fmtDateTime } = useDateFormat(); const { fmtDateTime } = useDateFormat();
const [instructors, setInstructors] = useState([]);
const [achievementKeys, setAchievementKeys] = useState([]);
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
useEffect(() => { useEffect(() => {
fetchCourse(courseId); fetchCourse(courseId);
// Instructors
api.get(`/admin/courses/${courseId}/instructors`)
.then(({ data }) => setInstructors(data.data?.data ?? data.data ?? []))
.catch(() => {});
// Achievements
api.get(`/admin/courses/${courseId}/achievements`)
.then(({ data }) => {
const rows = data?.data?.data ?? [];
setAchievementKeys(rows.map((r) => r.achievement_key));
})
.catch(() => {});
}, [courseId]); }, [courseId]);
// Fresh stream token for the badge image once course loads
useEffect(() => {
if (!course?.badge_asset_id) {
setBadgeImageUrl(course?.badge_image_url ?? null);
return;
}
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
api.post("/admin/media/token", { asset_id: course.badge_asset_id })
.then(({ data }) => {
const thumbUrl = data.data?.thumbnail_url;
const token = data.data?.token;
setBadgeImageUrl(thumbUrl ?? (token ? `${STREAM_BASE}/${token}` : null));
})
.catch(() => setBadgeImageUrl(course.badge_image_url ?? null));
}, [course?.badge_asset_id, course?.badge_image_url]);
return ( return (
<section className="bg-muted/60 min-h-full"> <section className="bg-muted/60 min-h-full">
<PageMeta title={course ? `${course.title} - STARR` : undefined} description={course?.description} /> <PageMeta title={course ? `${course.title} - STARR` : undefined} description={course?.description} />
@@ -155,9 +191,7 @@ export default function ViewCourse() {
<SectionCard icon={Clock} title="Duration & Stats"> <SectionCard icon={Clock} title="Duration & Stats">
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<InfoRow label="Duration"> <InfoRow label="Duration">
{course.duration_formatted ?? course.duration_seconds {course.duration_formatted ?? (course.duration_seconds ? `${course.duration_seconds}s` : "—")}
? `${course.duration_seconds}s`
: "—"}
</InfoRow> </InfoRow>
<InfoRow label="Units"> <InfoRow label="Units">
<div className="flex items-center gap-1.5 mt-0.5"> <div className="flex items-center gap-1.5 mt-0.5">
@@ -188,6 +222,102 @@ export default function ViewCourse() {
</SectionCard> </SectionCard>
)} )}
{/* ── Instructors ── */}
<SectionCard icon={Users} title="Course Instructors">
{instructors.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No instructors assigned.</p>
) : (
<ul className="space-y-3">
{instructors.map((inst, i) => {
const fullName = inst.user?.personal_info?.name?.full_name ?? null;
const email = inst.user?.email ?? null;
return (
<li key={inst.id ?? i} className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center shrink-0">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium leading-tight">{inst.display_name}</p>
{(fullName || email) && (
<p className="text-xs text-muted-foreground truncate">
{fullName ?? email}
</p>
)}
</div>
<Badge variant="outline" className="ml-auto text-[10px] shrink-0">
#{i + 1}
</Badge>
</li>
);
})}
</ul>
)}
</SectionCard>
{/* ── Rewards ── */}
<SectionCard icon={Award} title="Rewards">
<div className="space-y-5">
{/* Completion badge */}
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-3">Completion Badge</p>
<div className="flex items-start gap-4">
<CourseBadge
title={course.title}
level={course.level}
color={course.badge_color ?? "purple"}
imageUrl={badgeImageUrl}
/>
<div className="flex flex-col gap-1.5 text-xs text-muted-foreground pt-1">
<div><span className="font-medium text-foreground">Label:</span> Course Completion</div>
<div><span className="font-medium text-foreground">Trigger:</span> Pass course assessment</div>
<div><span className="font-medium text-foreground">Type:</span> Milestone achievement</div>
<div><span className="font-medium text-foreground">Color:</span> <span className="capitalize">{course.badge_color ?? "purple"}</span></div>
<Badge className="self-start mt-1 bg-green-100 text-green-700 border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 text-xs">
<BadgeCheck className="size-3" /> Mandatory
</Badge>
</div>
</div>
</div>
{/* Achievements */}
<div className="border-t pt-4">
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-3">
Achievements
<span className="ml-2 normal-case font-normal">({achievementKeys.length}/3)</span>
</p>
{achievementKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No achievements assigned.</p>
) : (
<ul className="space-y-2">
{achievementKeys.map((key) => {
const ach = ACHIEVEMENT_REGISTRY.find((a) => a.key === key);
return (
<li key={key} className="flex items-start gap-2.5 text-sm">
<div className="mt-0.5 shrink-0">
{ach?.type === "badge"
? <Trophy className="h-4 w-4 text-amber-500" />
: <BadgeCheck className="h-4 w-4 text-primary" />
}
</div>
<div className="min-w-0">
<span className="font-medium">{ach?.label ?? key}</span>
{ach?.description && (
<p className="text-xs text-muted-foreground leading-snug mt-0.5">{ach.description}</p>
)}
</div>
<Badge variant="outline" className="ml-auto text-[9px] capitalize shrink-0 mt-0.5">
{ach?.type ?? "badge"}
</Badge>
</li>
);
})}
</ul>
)}
</div>
</div>
</SectionCard>
{/* ── Prerequisites ── */} {/* ── Prerequisites ── */}
{course.prerequisites?.length > 0 && ( {course.prerequisites?.length > 0 && (
<SectionCard icon={Star} title="Prerequisites"> <SectionCard icon={Star} title="Prerequisites">
@@ -207,11 +337,11 @@ export default function ViewCourse() {
<SectionCard icon={Lock} title="Final Assessment"> <SectionCard icon={Lock} title="Final Assessment">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<InfoRow label="Title">{course.assessment.title ?? "Untitled Assessment"}</InfoRow> <InfoRow label="Title">{course.assessment.title ?? "Untitled Assessment"}</InfoRow>
<InfoRow label="Required"> {/* <InfoRow label="Required">
<Badge variant={course.assessment.is_required ? "default" : "secondary"}> <Badge variant={course.assessment.is_required ? "default" : "secondary"}>
{course.assessment.is_required ? "Required" : "Optional"} {course.assessment.is_required ? "Required" : "Optional"}
</Badge> </Badge>
</InfoRow> </InfoRow> */}
<InfoRow label="Passing Score">{course.assessment.passing_score ?? 70}%</InfoRow> <InfoRow label="Passing Score">{course.assessment.passing_score ?? 70}%</InfoRow>
<InfoRow label="Time Limit"> <InfoRow label="Time Limit">
{course.assessment.time_limit_minutes {course.assessment.time_limit_minutes
@@ -247,4 +377,4 @@ export default function ViewCourse() {
</div> </div>
</section> </section>
); );
} }
@@ -57,7 +57,7 @@ export default function AddLesson() {
}; };
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta title={unit ? `Add Lesson – ${unit.title} - STARR` : undefined} /> <PageMeta title={unit ? `Add Lesson – ${unit.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
@@ -41,7 +41,7 @@ export default function ArchivedLessonsList() {
} }
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta title={unit ? `Archived Lessons – ${unit.title} - STARR` : undefined} /> <PageMeta title={unit ? `Archived Lessons – ${unit.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6"> <div className="flex flex-col gap-2 my-6">
@@ -74,7 +74,7 @@ export default function EditLesson() {
}; };
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta title={lessonTitle ? `Edit: ${lessonTitle} - STARR` : undefined} /> <PageMeta title={lessonTitle ? `Edit: ${lessonTitle} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
@@ -19,7 +19,9 @@ export default function LessonsList() {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await fetchCourse(courseId) if (!course || String(course.course_id) !== String(courseId)) {
await fetchCourse(courseId);
}
await fetchUnit(courseId, unitId); await fetchUnit(courseId, unitId);
setInitializing(false); setInitializing(false);
})(); })();
@@ -42,7 +44,7 @@ export default function LessonsList() {
} }
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta title={unit ? `Lessons – ${unit.title} - STARR` : undefined} /> <PageMeta title={unit ? `Lessons – ${unit.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6"> <div className="flex flex-col gap-2 my-6">
@@ -43,7 +43,7 @@ export default function ViewLesson() {
} }
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta title={lesson ? `${lesson.title} - STARR` : undefined} /> <PageMeta title={lesson ? `${lesson.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
<div className="w-full max-w-2xl space-y-6"> <div className="w-full max-w-2xl space-y-6">
@@ -48,7 +48,7 @@ export default function AddUnit() {
}; };
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta title={course ? `Add Unit – ${course.title} - STARR` : undefined} /> <PageMeta title={course ? `Add Unit – ${course.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
@@ -39,7 +39,7 @@ export default function ArchivedUnitsList() {
} }
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta title={course ? `Archived Units – ${course.title} - STARR` : undefined} /> <PageMeta title={course ? `Archived Units – ${course.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6"> <div className="flex flex-col gap-2 my-6">
@@ -60,7 +60,7 @@ export default function EditUnit() {
}; };
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta title={unitTitle ? `Edit: ${unitTitle} - STARR` : undefined} /> <PageMeta title={unitTitle ? `Edit: ${unitTitle} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
@@ -19,7 +19,9 @@ export default function UnitsList() {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
await fetchCourse(courseId); if (!course || String(course.course_id) !== String(courseId)) {
await fetchCourse(courseId);
}
setInitializing(false); setInitializing(false);
})(); })();
}, [courseId]); }, [courseId]);
@@ -40,7 +42,7 @@ export default function UnitsList() {
} }
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta title={course ? `Units - ${course.title} - STARR` : undefined} /> <PageMeta title={course ? `Units - ${course.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6"> <div className="flex flex-col gap-2 my-6">
@@ -31,7 +31,7 @@ export default function ViewUnit() {
} }
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta title={unit ? `${unit.title} - STARR` : undefined} /> <PageMeta title={unit ? `${unit.title} - STARR` : undefined} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4 py-10">
@@ -39,7 +39,7 @@ export default function ViewUnit() {
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}> <Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/courses/${courseId}/units`)}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
<div> <div>
@@ -10,7 +10,7 @@ export default function ArchiveTaskList() {
]; ];
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6"> <div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
@@ -9,7 +9,7 @@ export default function TaskList() {
]; ];
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6"> <div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
@@ -13,7 +13,7 @@ export default function ArchivedTask() {
]; ];
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6"> <div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
+72 -17
View File
@@ -7,8 +7,9 @@ import { ArrowLeft, House } from "lucide-react";
import { useTiers } from "@/contexts/AdminTiersContext"; import { useTiers } from "@/contexts/AdminTiersContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
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 { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
@@ -17,12 +18,38 @@ import api from "@/utils/api.util";
// ─── Schema ─────────────────────────────────────────────────────────────────── // ─── Schema ───────────────────────────────────────────────────────────────────
const DURATION_UNITS = [
{ value: "minute", label: "Minute(s)" },
{ value: "hour", label: "Hour(s)" },
{ value: "day", label: "Day(s)" },
{ value: "month", label: "Month(s)" },
{ value: "year", label: "Year(s)" },
];
const DURATION_UNIT_LIMITS = {
minute: { max: 59, nextLabel: "Hour(s)", factor: 60 },
hour: { max: 23, nextLabel: "Day(s)", factor: 24 },
month: { max: 11, nextLabel: "Year(s)", factor: 12 },
};
const schema = z.object({ const schema = z.object({
tier_category_id: z.string().min(1, "Tier category is required."), tier_category_id: z.string().min(1, "Tier category is required."),
label: z.string().min(1, "Label is required."), label: z.string().min(1, "Label is required."),
duration_days: z.coerce.number().min(1, "Duration must be at least 1 day."), description: z.string().optional(),
duration_value: z.coerce.number().min(1, "Duration must be at least 1."),
duration_unit: z.string().min(1),
price: z.coerce.number().min(0.01, "Price must be greater than 0."), price: z.coerce.number().min(0.01, "Price must be greater than 0."),
currency: z.string().length(3, "Must be a 3-letter currency code.").default("USD"), currency: z.string().length(3, "Must be a 3-letter currency code.").default("USD"),
}).superRefine(({ duration_value, duration_unit }, ctx) => {
const rule = DURATION_UNIT_LIMITS[duration_unit];
if (rule && duration_value > rule.max) {
const equivalent = Math.floor(duration_value / rule.factor);
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["duration_value"],
message: `${duration_value} ${duration_unit}(s) = ${equivalent}+ ${rule.nextLabel.toLowerCase()}. Use ${rule.nextLabel} instead.`,
});
}
}); });
function FieldError({ message }) { function FieldError({ message }) {
@@ -58,7 +85,7 @@ export default function AddPlan() {
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({ const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { tier_category_id: "", label: "", duration_days: 30, price: "", currency: "USD" }, defaultValues: { tier_category_id: "", label: "", description: "", duration_value: 30, duration_unit: "day", price: "", currency: "USD" },
}); });
const selectedCategoryId = watch("tier_category_id"); const selectedCategoryId = watch("tier_category_id");
@@ -154,17 +181,48 @@ export default function AddPlan() {
<FieldError message={errors.label?.message} /> <FieldError message={errors.label?.message} />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="space-y-1.5">
<div className="space-y-1.5"> <Label htmlFor="description">Description</Label>
<Label htmlFor="duration_days">Duration (days) <span className="text-destructive">*</span></Label> <Textarea
<Input id="duration_days" type="number" min={1} {...register("duration_days")} /> id="description"
<FieldError message={errors.duration_days?.message} /> placeholder="Brief description shown to users on the plans page."
</div> rows={3}
<div className="space-y-1.5"> {...register("description")}
<Label htmlFor="price">Price <span className="text-destructive">*</span></Label> />
<Input id="price" type="number" step="0.01" min={0} placeholder="0.00" {...register("price")} /> <FieldError message={errors.description?.message} />
<FieldError message={errors.price?.message} /> </div>
<div className="space-y-1.5">
<Label>Duration <span className="text-destructive">*</span></Label>
<div className="flex gap-2">
<Input
id="duration_value"
type="number"
min={1}
className="flex-1"
{...register("duration_value")}
/>
<Select
value={watch("duration_unit") ?? "day"}
onValueChange={(v) => setValue("duration_unit", v, { shouldDirty: true })}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DURATION_UNITS.map((u) => (
<SelectItem key={u.value} value={u.value}>{u.label}</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<FieldError message={errors.duration_value?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="price">Price <span className="text-destructive">*</span></Label>
<Input id="price" type="number" step="0.01" min={0} placeholder="0.00" {...register("price")} />
<FieldError message={errors.price?.message} />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -176,9 +234,6 @@ export default function AddPlan() {
{categorySlug && ( {categorySlug && (
<SectionCard title="Assigned Courses"> <SectionCard title="Assigned Courses">
<p className="text-xs text-muted-foreground -mt-1">
Select which <span className="font-medium capitalize">{categorySlug}</span> courses are included in this plan.
</p>
<CoursePicker <CoursePicker
subscription={categorySlug} subscription={categorySlug}
selectedIds={selectedCourseIds} selectedIds={selectedCourseIds}
@@ -0,0 +1,32 @@
import { useEffect } from "react";
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import ArchivedTierPlansTable from "../../components/tiers/ArchivedTierPlansTable";
import { useTiers } from "@/contexts/AdminTiersContext";
import { PageMeta } from "@/contexts/MetadataContext";
export default function ArchivedPlanList() {
const { fetchPlans } = useTiers();
useEffect(() => { fetchPlans({ archived: true }); }, []);
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tiers", to: "/admin/tiers/plans" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: "Archived" },
];
return (
<section className="bg-muted h-full">
<PageMeta title="Archived Plans - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={items} />
</div>
<div className="w-full">
<ArchivedTierPlansTable />
</div>
</div>
</section>
);
}
+210 -44
View File
@@ -3,25 +3,61 @@ import { useNavigate, useParams } from "react-router-dom";
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";
import { ArrowLeft, House } from "lucide-react"; import { ArrowLeft, House, TriangleAlert } from "lucide-react";
import { useTiers } from "@/contexts/AdminTiersContext"; import { useTiers } from "@/contexts/AdminTiersContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
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 { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker"; import { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
const DURATION_UNITS = [
{ value: "minute", label: "Minute(s)" },
{ value: "hour", label: "Hour(s)" },
{ value: "day", label: "Day(s)" },
{ value: "month", label: "Month(s)" },
{ value: "year", label: "Year(s)" },
];
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
function durationDaysToValue(days, unit) {
const multiplier = UNIT_TO_DAYS[unit] ?? 1;
return Math.round((days / multiplier) * 1000) / 1000;
}
const DURATION_UNIT_LIMITS = {
minute: { max: 59, nextLabel: "Hour(s)", factor: 60 },
hour: { max: 23, nextLabel: "Day(s)", factor: 24 },
month: { max: 11, nextLabel: "Year(s)", factor: 12 },
};
const schema = z.object({ const schema = z.object({
label: z.string().min(1, "Label is required."), label: z.string().min(1, "Label is required."),
duration_days: z.coerce.number().min(1, "Duration must be at least 1 day."), description: z.string().optional(),
price: z.coerce.number().min(0.01, "Price must be greater than 0."), duration_value: z.coerce.number().min(1, "Duration must be at least 1."),
currency: z.string().length(3), duration_unit: z.string().min(1),
is_active: z.boolean().default(true), price: z.coerce.number().min(0.01, "Price must be greater than 0."),
currency: z.string().length(3),
is_active: z.boolean().default(true),
}).superRefine(({ duration_value, duration_unit }, ctx) => {
const rule = DURATION_UNIT_LIMITS[duration_unit];
if (rule && duration_value > rule.max) {
const equivalent = Math.floor(duration_value / rule.factor);
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["duration_value"],
message: `${duration_value} ${duration_unit}(s) = ${equivalent}+ ${rule.nextLabel.toLowerCase()}. Use ${rule.nextLabel} instead.`,
});
}
}); });
function FieldError({ message }) { function FieldError({ message }) {
@@ -44,8 +80,12 @@ export default function EditPlan() {
const { planId } = useParams(); const { planId } = useParams();
const { fetchPlan, plan, updatePlan, loading } = useTiers(); const { fetchPlan, plan, updatePlan, loading } = useTiers();
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set()); const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
const [coursesLoaded, setCoursesLoaded] = useState(false); const [coursesLoaded, setCoursesLoaded] = useState(false);
const [impactDialog, setImpactDialog] = useState(false);
const [impactCount, setImpactCount] = useState(0);
const [impactLoading, setImpactLoading] = useState(false);
const [pendingValues, setPendingValues] = useState(null);
const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm({ const { register, handleSubmit, setValue, watch, reset, formState: { errors } } = useForm({
resolver: zodResolver(schema), resolver: zodResolver(schema),
@@ -57,12 +97,15 @@ export default function EditPlan() {
useEffect(() => { useEffect(() => {
if (plan) { if (plan) {
const unit = plan.duration_unit ?? "day";
reset({ reset({
label: plan.label, label: plan.label,
duration_days: plan.duration_days, description: plan.description ?? "",
price: plan.price, duration_value: durationDaysToValue(plan.duration_days, unit),
currency: plan.currency, duration_unit: unit,
is_active: plan.is_active, price: plan.price,
currency: plan.currency,
is_active: plan.is_active,
}); });
} }
}, [plan]); }, [plan]);
@@ -70,7 +113,7 @@ export default function EditPlan() {
// Load existing assigned courses once the plan is known // Load existing assigned courses once the plan is known
useEffect(() => { useEffect(() => {
if (!planId || coursesLoaded) return; if (!planId || coursesLoaded) return;
api.get(`/admin/tiers/plans/${planId}/courses`) api.get(`/admin/tiers/${planId}/courses`)
.then(({ data }) => { .then(({ data }) => {
const ids = (data.data ?? []).map((c) => String(c.course_id)); const ids = (data.data ?? []).map((c) => String(c.course_id));
setSelectedCourseIds(new Set(ids)); setSelectedCourseIds(new Set(ids));
@@ -79,18 +122,51 @@ export default function EditPlan() {
.catch(() => setCoursesLoaded(true)); .catch(() => setCoursesLoaded(true));
}, [planId]); }, [planId]);
const onSubmit = async (values) => { const durationChanged = (values) => {
if (!plan) return false;
const originalUnit = plan.duration_unit ?? "day";
const originalValue = durationDaysToValue(plan.duration_days, originalUnit);
return (
Number(values.duration_value) !== originalValue ||
values.duration_unit !== originalUnit
);
};
const doSave = async (values) => {
const result = await updatePlan(planId, values); const result = await updatePlan(planId, values);
if (!result) return; if (!result) return;
await api.post(`/admin/tiers/${planId}/courses`, {
// Always sync (empty array clears all assignments) course_ids: [...selectedCourseIds],
await api.post(`/admin/tiers/plans/${planId}/courses`, {
course_ids: [...selectedCourseIds].map(Number),
}).catch(() => {}); }).catch(() => {});
navigate("/admin/tiers/plans"); navigate("/admin/tiers/plans");
}; };
const onSubmit = async (values) => {
if (!durationChanged(values)) {
return doSave(values);
}
setImpactLoading(true);
try {
const { data } = await api.get(`/admin/tiers/${planId}/impact`);
setImpactCount(data.data?.active_subscriber_count ?? 0);
setPendingValues(values);
setImpactDialog(true);
} catch {
// Impact fetch failed — still allow save but without count
setImpactCount(0);
setPendingValues(values);
setImpactDialog(true);
} finally {
setImpactLoading(false);
}
};
const handleConfirmSave = async () => {
setImpactDialog(false);
if (pendingValues) await doSave(pendingValues);
};
return ( return (
<section className="bg-muted/60 min-h-full"> <section className="bg-muted/60 min-h-full">
<PageMeta title={plan ? `Edit: ${plan.label} - STARR` : undefined} /> <PageMeta title={plan ? `Edit: ${plan.label} - STARR` : undefined} />
@@ -129,17 +205,48 @@ export default function EditPlan() {
<FieldError message={errors.label?.message} /> <FieldError message={errors.label?.message} />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="space-y-1.5">
<div className="space-y-1.5"> <Label htmlFor="description">Description</Label>
<Label htmlFor="duration_days">Duration (days)</Label> <Textarea
<Input id="duration_days" type="number" min={1} {...register("duration_days")} /> id="description"
<FieldError message={errors.duration_days?.message} /> placeholder="Brief description shown to users on the plans page."
</div> rows={3}
<div className="space-y-1.5"> {...register("description")}
<Label htmlFor="price">Price</Label> />
<Input id="price" type="number" step="0.01" {...register("price")} /> <FieldError message={errors.description?.message} />
<FieldError message={errors.price?.message} /> </div>
<div className="space-y-1.5">
<Label>Duration</Label>
<div className="flex gap-2">
<Input
id="duration_value"
type="number"
min={1}
className="flex-1"
{...register("duration_value")}
/>
<Select
value={watch("duration_unit") ?? "day"}
onValueChange={(v) => setValue("duration_unit", v, { shouldDirty: true })}
>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DURATION_UNITS.map((u) => (
<SelectItem key={u.value} value={u.value}>{u.label}</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<FieldError message={errors.duration_value?.message} />
</div>
<div className="space-y-1.5">
<Label htmlFor="price">Price</Label>
<Input id="price" type="number" step="0.01" {...register("price")} />
<FieldError message={errors.price?.message} />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
@@ -151,7 +258,7 @@ export default function EditPlan() {
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div> <div>
<p className="text-sm font-medium">Active</p> <p className="text-sm font-medium">Active</p>
<p className="text-xs text-muted-foreground">Inactive plans won't appear to users.</p> <p className="text-xs text-muted-foreground">Inactive plans are visible to users but cannot be purchased.</p>
</div> </div>
<Switch <Switch
checked={watch("is_active") ?? true} checked={watch("is_active") ?? true}
@@ -162,21 +269,29 @@ export default function EditPlan() {
{plan?.tier && ( {plan?.tier && (
<SectionCard title="Assigned Courses"> <SectionCard title="Assigned Courses">
<p className="text-xs text-muted-foreground -mt-1"> {coursesLoaded ? (
Select which <span className="font-medium capitalize">{plan.tier}</span> courses are included in this plan. <CoursePicker
</p> subscription={plan.tier}
<CoursePicker selectedIds={selectedCourseIds}
subscription={plan.tier} onChange={setSelectedCourseIds}
selectedIds={selectedCourseIds} isPreloaded={true}
onChange={setSelectedCourseIds} />
/> ) : (
<div className="space-y-3">
<div className="flex gap-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-8 w-40" />
</div>
<Skeleton className="h-8 w-52" />
</div>
)}
</SectionCard> </SectionCard>
)} )}
<div className="flex justify-end gap-3 pt-1"> <div className="flex justify-end gap-3 pt-1">
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button> <Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading || impactLoading}>Cancel</Button>
<Button type="submit" disabled={loading}> <Button type="submit" disabled={loading || impactLoading}>
{loading && <Spinner className="h-4 w-4 mr-2" />} {(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
Save Changes Save Changes
</Button> </Button>
</div> </div>
@@ -184,6 +299,57 @@ export default function EditPlan() {
)} )}
</div> </div>
</div> </div>
{/* ── Duration Impact Warning Dialog ──────────────────────────────────── */}
<Dialog open={impactDialog} onOpenChange={(v) => { if (!v) { setImpactDialog(false); setPendingValues(null); } }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<TriangleAlert className="h-5 w-5 shrink-0" />
Duration Change Warning
</DialogTitle>
<DialogDescription>
You are about to change the subscription duration for this plan.
</DialogDescription>
</DialogHeader>
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 space-y-2 text-sm">
{impactCount > 0 ? (
<>
<p className="font-semibold text-destructive">
{impactCount} active subscriber{impactCount !== 1 ? "s" : ""} will be affected.
</p>
<p className="text-muted-foreground">
Their current expiry dates are based on the <span className="font-medium text-foreground">previous duration</span>.
This change will only apply to <span className="font-medium text-foreground">new subscriptions</span> going forward — existing active subscribers will not have their expiry recalculated automatically.
</p>
</>
) : (
<p className="text-muted-foreground">
No active subscribers are currently on this plan. The new duration will apply to all future subscriptions.
</p>
)}
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => { setImpactDialog(false); setPendingValues(null); }}
disabled={loading}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleConfirmSave}
disabled={loading}
>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Confirm & Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section> </section>
); );
} }
@@ -4,29 +4,76 @@ import {
ArrowLeft, House, ShieldCheck, ImagePlus, X, Check, ArrowLeft, House, ShieldCheck, ImagePlus, X, Check,
Shield, Star, Trophy, Medal, Award, BadgeCheck, Gem, Shield, Star, Trophy, Medal, Award, BadgeCheck, Gem,
Crown, Zap, Flame, Sparkles, Rocket, Target, Hexagon, Layers, CircleDot, Crown, Zap, Flame, Sparkles, Rocket, Target, Hexagon, Layers, CircleDot,
Diamond, Swords, Mountain, Sun, Globe, Compass, Flag, GraduationCap,
Key, Fingerprint, Eye, Crosshair, Infinity, TrendingUp, Triangle, Octagon,
Circle, Disc, Feather, Snowflake, Leaf, TreePine, Gift, Heart,
HeartHandshake, Bird, BadgePlus, BadgePercent, Sigma, BookOpen,
Milestone, Navigation, Sunrise,
} from "lucide-react"; } from "lucide-react";
import * as LucideIcons from "lucide-react"; import * as LucideIcons from "lucide-react";
import { TIER_COLOR_OPTIONS, getTierColor } from "@/utils/tierColors"; import { TIER_COLOR_OPTIONS, getTierColor } from "@/utils/tierColors";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
const BADGE_ICON_OPTIONS = [ const BADGE_ICON_OPTIONS = [
{ name: "ShieldCheck", icon: ShieldCheck }, // Prestige / rank
{ name: "Shield", icon: Shield }, { name: "ShieldCheck", icon: ShieldCheck },
{ name: "BadgeCheck", icon: BadgeCheck }, { name: "Shield", icon: Shield },
{ name: "Star", icon: Star }, { name: "BadgeCheck", icon: BadgeCheck },
{ name: "Crown", icon: Crown }, { name: "BadgePlus", icon: BadgePlus },
{ name: "Gem", icon: Gem }, { name: "BadgePercent", icon: BadgePercent },
{ name: "Trophy", icon: Trophy }, { name: "Crown", icon: Crown },
{ name: "Medal", icon: Medal }, { name: "Star", icon: Star },
{ name: "Award", icon: Award }, { name: "Sparkles", icon: Sparkles },
{ name: "Sparkles", icon: Sparkles }, { name: "Diamond", icon: Diamond },
{ name: "Flame", icon: Flame }, { name: "Gem", icon: Gem },
{ name: "Zap", icon: Zap }, // Awards
{ name: "Rocket", icon: Rocket }, { name: "Trophy", icon: Trophy },
{ name: "Target", icon: Target }, { name: "Medal", icon: Medal },
{ name: "Hexagon", icon: Hexagon }, { name: "Award", icon: Award },
{ name: "Layers", icon: Layers }, { name: "Gift", icon: Gift },
{ name: "CircleDot", icon: CircleDot }, { name: "Milestone", icon: Milestone },
// Power / energy
{ name: "Flame", icon: Flame },
{ name: "Zap", icon: Zap },
{ name: "Rocket", icon: Rocket },
{ name: "Sun", icon: Sun },
{ name: "Sunrise", icon: Sunrise },
// Action / skill
{ name: "Swords", icon: Swords },
{ name: "Target", icon: Target },
{ name: "Crosshair", icon: Crosshair },
{ name: "TrendingUp", icon: TrendingUp },
{ name: "Sigma", icon: Sigma },
// Navigation / scope
{ name: "Compass", icon: Compass },
{ name: "Navigation", icon: Navigation },
{ name: "Globe", icon: Globe },
{ name: "Mountain", icon: Mountain },
{ name: "Flag", icon: Flag },
// Knowledge
{ name: "GraduationCap", icon: GraduationCap },
{ name: "BookOpen", icon: BookOpen },
{ name: "Key", icon: Key },
{ name: "Fingerprint", icon: Fingerprint },
{ name: "Eye", icon: Eye },
// Nature
{ name: "Leaf", icon: Leaf },
{ name: "TreePine", icon: TreePine },
{ name: "Snowflake", icon: Snowflake },
{ name: "Bird", icon: Bird },
{ name: "Feather", icon: Feather },
// Heart
{ name: "Heart", icon: Heart },
{ name: "HeartHandshake",icon: HeartHandshake },
{ name: "Infinity", icon: Infinity },
// Shapes
{ name: "Hexagon", icon: Hexagon },
{ name: "Octagon", icon: Octagon },
{ name: "Triangle", icon: Triangle },
{ name: "Circle", icon: Circle },
{ name: "Disc", icon: Disc },
{ name: "CircleDot", icon: CircleDot },
{ name: "Layers", icon: Layers },
]; ];
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -0,0 +1,525 @@
import { useEffect, useState, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Globe, House, Plus, Trash2, Loader2, Pencil, Check, X } from "lucide-react";
import { toast } from "sonner";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { PageMeta } from "@/contexts/MetadataContext";
import { useTiers } from "@/contexts/AdminTiersContext";
import api from "@/utils/api.util";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function SectionCard({ icon: Icon, title, description, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
<div>
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5 ml-6">{description}</p>}
</div>
<Separator />
{children}
</div>
);
}
const EMPTY_FORM = { currency: "", price: "" };
// ─── Rate-hint helpers ────────────────────────────────────────────────────────
const LOWER_HARD = 0.70;
const LOWER_WARN = 0.85;
const UPPER_WARN = 1.50;
const UPPER_HARD = 3.00;
function computeZone(price, hint) {
if (!hint || !price || Number(price) === 0) return null;
const n = Number(price);
if (isNaN(n)) return null;
if (n < hint.hardMin || n > hint.hardMax) return "block";
if (n < hint.warnMin || n > hint.warnMax) return "warn";
return "pass";
}
const ZONE_INPUT = {
block: "border-red-400 focus-visible:ring-red-400",
warn: "border-yellow-400 focus-visible:ring-yellow-400",
pass: "border-green-400 focus-visible:ring-green-400",
};
const ZONE_MSG = {
block: (h, c) => `Outside acceptable range: ${h.hardMin.toFixed(2)} – ${h.hardMax.toFixed(2)} ${c}`,
warn: (h, c) => `Outside suggested range: ${h.warnMin.toFixed(2)} – ${h.warnMax.toFixed(2)} ${c}. Will save with caution.`,
pass: () => `Price looks good.`,
};
const ZONE_TEXT = { block: "text-red-500", warn: "text-yellow-600", pass: "text-green-600" };
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function LocalizedPrices() {
const navigate = useNavigate();
const { planId } = useParams();
const { fetchPlan, plan, loading: planLoading, plans, fetchPlans } = useTiers();
const [prices, setPrices] = useState([]);
const [pricesLoading, setPricesLoading] = useState(true);
const [currencies, setCurrencies] = useState([]);
// When accessed from toolbar (no planId), show plan picker first
const [selectedPlanId, setSelectedPlanId] = useState(planId ?? "");
const [addForm, setAddForm] = useState(EMPTY_FORM);
const [showAdd, setShowAdd] = useState(false);
const [adding, setAdding] = useState(false);
// Inline edit state: { [currency]: price }
const [editingRow, setEditingRow] = useState(null); // currency string
const [editPrice, setEditPrice] = useState("");
const [savingEdit, setSavingEdit] = useState(false);
const [removingCurrency, setRemovingCurrency] = useState(null);
// Rate hint for the add form
const [rateHint, setRateHint] = useState(null);
const [rateHintLoading, setRateHintLoading] = useState(false);
// Rate hint for inline edit
const [editRateHint, setEditRateHint] = useState(null);
const activePlanId = planId ?? selectedPlanId;
const activePlan = plan?.plan_id === Number(activePlanId) ? plan
: plans.find((p) => String(p.plan_id) === String(activePlanId));
// ─── Load ──────────────────────────────────────────────────────────────────
const loadPrices = useCallback(async (id) => {
if (!id) return;
setPricesLoading(true);
try {
const { data } = await api.get(`/admin/tiers/${id}/prices`);
setPrices(data.data ?? []);
} catch {
toast.error("Could not load localized prices.");
} finally {
setPricesLoading(false);
}
}, []);
useEffect(() => {
api.get("/client/tiers/currencies")
.then(({ data }) => setCurrencies(data.data ?? []))
.catch(() => {});
}, []);
useEffect(() => {
if (!plans.length) fetchPlans();
}, []);
useEffect(() => {
if (!activePlanId) return;
fetchPlan(activePlanId);
loadPrices(activePlanId);
}, [activePlanId]);
// Fetch rate when currency is selected in the add form
useEffect(() => {
if (!addForm.currency || !activePlan) { setRateHint(null); return; }
setRateHintLoading(true);
fetch(`https://api.frankfurter.app/latest?from=${activePlan.currency}&to=${addForm.currency}`)
.then((r) => r.json())
.then((json) => {
const rate = json?.rates?.[addForm.currency];
if (!rate) { setRateHint(null); return; }
const expected = Number(activePlan.price) * rate;
setRateHint({ rate, expected, hardMin: expected * LOWER_HARD, hardMax: expected * UPPER_HARD,
warnMin: expected * LOWER_WARN, warnMax: expected * UPPER_WARN });
})
.catch(() => setRateHint(null))
.finally(() => setRateHintLoading(false));
}, [addForm.currency, activePlan?.plan_id]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch rate when opening an inline edit row
useEffect(() => {
if (!editingRow || !activePlan) { setEditRateHint(null); return; }
fetch(`https://api.frankfurter.app/latest?from=${activePlan.currency}&to=${editingRow}`)
.then((r) => r.json())
.then((json) => {
const rate = json?.rates?.[editingRow];
if (!rate) { setEditRateHint(null); return; }
const expected = Number(activePlan.price) * rate;
setEditRateHint({ rate, expected, hardMin: expected * LOWER_HARD, hardMax: expected * UPPER_HARD,
warnMin: expected * LOWER_WARN, warnMax: expected * UPPER_WARN });
})
.catch(() => setEditRateHint(null));
}, [editingRow, activePlan?.plan_id]); // eslint-disable-line react-hooks/exhaustive-deps
// ─── Actions ───────────────────────────────────────────────────────────────
const usedCurrencies = new Set(prices.map((p) => p.currency));
const availableCurrencies = currencies.filter(
(c) => !usedCurrencies.has(c.code) && c.code !== activePlan?.currency
);
const handleAdd = async () => {
if (!addForm.currency) { toast.error("Select a currency."); return; }
if (!addForm.price || Number(addForm.price) < 0) { toast.error("Enter a valid price."); return; }
const zone = computeZone(addForm.price, rateHint);
if (zone === "block") { toast.error("Price is outside the acceptable range. Adjust it before saving."); return; }
setAdding(true);
try {
const res = await api.post(`/admin/tiers/${activePlanId}/prices`, {
currency: addForm.currency,
price: Number(addForm.price),
});
if (res.data?.warning) toast.warning(res.data.message);
else toast.success("Localized price added.");
setAddForm(EMPTY_FORM);
setShowAdd(false);
setRateHint(null);
loadPrices(activePlanId);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not add price.");
} finally {
setAdding(false);
}
};
const handleEditSave = async (currency) => {
if (editPrice === "" || Number(editPrice) < 0) { toast.error("Enter a valid price."); return; }
const zone = computeZone(editPrice, editRateHint);
if (zone === "block") { toast.error("Price is outside the acceptable range."); return; }
setSavingEdit(true);
try {
const res = await api.put(`/admin/tiers/${activePlanId}/prices/${currency}`, { price: Number(editPrice) });
if (res.data?.warning) toast.warning(res.data.message);
else toast.success("Price updated.");
setEditingRow(null);
setEditRateHint(null);
loadPrices(activePlanId);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not update price.");
} finally {
setSavingEdit(false);
}
};
const handleRemove = async (currency) => {
setRemovingCurrency(currency);
try {
await api.delete(`/admin/tiers/${activePlanId}/prices/${currency}`);
toast.success(`${currency} price removed.`);
loadPrices(activePlanId);
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not remove price.");
} finally {
setRemovingCurrency(null);
}
};
// ─── Render ────────────────────────────────────────────────────────────────
const isLoading = planLoading || pricesLoading;
// ── Plan picker (toolbar entry, no planId in URL) ──────────────────────────
if (!planId) {
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Localized Prices - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: "Localized Prices" },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Localized Prices</h1>
<p className="text-sm text-muted-foreground">Select a plan to manage its currency overrides.</p>
</div>
</div>
<div className="rounded-lg border bg-card p-5 space-y-4">
<div className="space-y-1.5">
<Label>Plan</Label>
<Select value={selectedPlanId} onValueChange={setSelectedPlanId}>
<SelectTrigger>
<SelectValue placeholder="Select a plan…" />
</SelectTrigger>
<SelectContent>
{plans.filter((p) => !p.deletedAt).map((p) => (
<SelectItem key={p.plan_id} value={String(p.plan_id)}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedPlanId && (
<Button onClick={() => navigate(`/admin/tiers/plans/${selectedPlanId}/prices`)}>
Manage Prices →
</Button>
)}
</div>
</div>
</div>
</section>
);
}
// ── Per-plan management ────────────────────────────────────────────────────
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={activePlan ? `Localized Prices — ${activePlan.label} - STARR` : "Localized Prices - STARR"} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: activePlan?.label ?? `Plan #${planId}`, to: `/admin/tiers/plans/${planId}/view` },
{ label: "Localized Prices" },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Localized Prices</h1>
<p className="text-sm text-muted-foreground capitalize">
{activePlan?.tier} — {activePlan?.label}
</p>
</div>
</div>
{isLoading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-20 w-full" />)}
</div>
) : (
<SectionCard
icon={Globe}
title="Currency Overrides"
description={`Base price is ${activePlan?.currency ?? "USD"} ${Number(activePlan?.price ?? 0).toFixed(2)}. Overrides take priority when a user's preferred currency matches.`}
>
{/* ── Existing prices ─────────────────────────────────────── */}
{prices.length > 0 ? (
<div className="space-y-2">
{prices.map((entry) => {
const isEditing = editingRow === entry.currency;
const currencyMeta = currencies.find((c) => c.code === entry.currency);
return (
<div
key={entry.currency}
className="flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-4 py-3"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<Badge variant="outline" className="font-mono text-xs shrink-0">
{entry.currency}
</Badge>
{currencyMeta && (
<span className="text-xs text-muted-foreground shrink-0">
{currencyMeta.name}
</span>
)}
{isEditing ? (
<div className="flex flex-col gap-0.5">
{(() => {
const zone = computeZone(editPrice, editRateHint);
return (
<>
<Input
type="number"
step="0.01"
min="0"
className={`h-7 w-28 text-sm ${zone ? ZONE_INPUT[zone] : ""}`}
value={editPrice}
autoFocus
onChange={(e) => setEditPrice(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleEditSave(entry.currency);
if (e.key === "Escape") { setEditingRow(null); setEditRateHint(null); }
}}
/>
{editRateHint && (
<p className="text-[10px] text-muted-foreground">
Good: {editRateHint.warnMin.toFixed(2)} – {editRateHint.warnMax.toFixed(2)}
</p>
)}
{zone && editPrice && (
<p className={`text-[10px] ${ZONE_TEXT[zone]}`}>
{zone === "block" ? "Out of range" : zone === "warn" ? "Caution" : ""}
</p>
)}
</>
);
})()}
</div>
) : (
<span className="text-sm font-semibold tabular-nums">
{Number(entry.price).toFixed(2)}
</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{isEditing ? (
<>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-green-600 hover:text-green-500"
disabled={savingEdit}
onClick={() => handleEditSave(entry.currency)}
>
{savingEdit ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7"
onClick={() => { setEditingRow(null); setEditRateHint(null); }}
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
<Button
variant="ghost" size="icon" className="h-7 w-7"
onClick={() => { setEditingRow(entry.currency); setEditPrice(String(entry.price)); }}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive"
disabled={removingCurrency === entry.currency}
onClick={() => handleRemove(entry.currency)}
>
{removingCurrency === entry.currency
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Trash2 className="h-3.5 w-3.5" />}
</Button>
</>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Globe className="size-4 shrink-0" />
No localized prices yet. All users see the base price.
</div>
)}
{/* ── Add form ────────────────────────────────────────────── */}
{showAdd ? (
<div className="rounded-lg border bg-muted/20 p-4 space-y-4">
<p className="text-sm font-medium">Add Localized Price</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Currency <span className="text-destructive">*</span></Label>
<Select
value={addForm.currency}
onValueChange={(v) => setAddForm((f) => ({ ...f, currency: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{availableCurrencies.map((c) => (
<SelectItem key={c.code} value={c.code}>
{c.code} — {c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>Price <span className="text-destructive">*</span></Label>
{(() => {
const zone = computeZone(addForm.price, rateHint);
return (
<>
<Input
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={addForm.price}
className={zone ? ZONE_INPUT[zone] : ""}
onChange={(e) => setAddForm((f) => ({ ...f, price: e.target.value }))}
/>
{rateHintLoading && (
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" /> Fetching rate…
</p>
)}
{rateHint && !rateHintLoading && (
<p className="text-xs text-muted-foreground">
1 {activePlan.currency} ≈ {rateHint.rate.toFixed(4)} {addForm.currency}
{" · "}Good range: {rateHint.warnMin.toFixed(2)} – {rateHint.warnMax.toFixed(2)}
</p>
)}
{zone && addForm.price && (
<p className={`text-xs ${ZONE_TEXT[zone]}`}>
{ZONE_MSG[zone]?.(rateHint, addForm.currency)}
</p>
)}
</>
);
})()}
</div>
</div>
<div className="flex gap-2 justify-end pt-1">
<Button
variant="outline" size="sm"
onClick={() => { setShowAdd(false); setAddForm(EMPTY_FORM); }}
disabled={adding}
>
Cancel
</Button>
<Button size="sm" onClick={handleAdd} disabled={adding}>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4 mr-1" />}
Add Price
</Button>
</div>
</div>
) : (
<Button
variant="outline" size="sm"
onClick={() => setShowAdd(true)}
disabled={availableCurrencies.length === 0}
>
<Plus className="h-4 w-4 mr-1" />
{availableCurrencies.length === 0 ? "All currencies configured" : "Add Currency"}
</Button>
)}
</SectionCard>
)}
</div>
</div>
</section>
);
}
@@ -24,7 +24,7 @@ export default function PaymentList() {
]; ];
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta title="Payments - STARR" /> <PageMeta title="Payments - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6"> <div className="flex flex-col gap-2 my-6">
@@ -0,0 +1,492 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, CreditCard, Tag, ShieldCheck, Plus, Trash2, Loader2, Globe, ExternalLink } from "lucide-react";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { toast } from "sonner";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { PageMeta } from "@/contexts/MetadataContext";
import { useTiers } from "@/contexts/AdminTiersContext";
import api from "@/utils/api.util";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function SectionCard({ icon: Icon, title, description, children }) {
return (
<div className="rounded-lg border bg-card p-5 space-y-4">
<div>
<div className="flex items-center gap-2">
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
<h2 className="text-sm font-semibold">{title}</h2>
</div>
{description && <p className="text-xs text-muted-foreground mt-0.5 ml-6">{description}</p>}
</div>
<Separator />
{children}
</div>
);
}
const EMPTY_PROMO = {
code: "", type: "flat", value: "", currency: "USD",
max_discount: "", max_uses: "", expires_at: "", min_amount: "",
};
const WINDOW_UNITS = ["minutes", "hours", "days"];
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function PaymentPolicy() {
const navigate = useNavigate();
const { planId } = useParams();
const { fetchPlan, plan, loading: planLoading } = useTiers();
const [policy, setPolicy] = useState(null);
const [policyLoading, setPolicyLoading] = useState(true);
const [saving, setSaving] = useState(false);
// ── Refund policy fields
const [refundAllowed, setRefundAllowed] = useState(true);
const [refundWindowValue, setRefundWindowValue] = useState(5);
const [refundWindowUnit, setRefundWindowUnit] = useState("minutes");
const [refundReasonReqd, setRefundReasonReqd] = useState(false);
// ── Promo rules
const [promoRules, setPromoRules] = useState([]);
const [addForm, setAddForm] = useState(EMPTY_PROMO);
const [showAdd, setShowAdd] = useState(false);
// ── Localized prices (for notice in promo section)
const [localizedPrices, setLocalizedPrices] = useState([]);
// ─── Load ────────────────────────────────────────────────────────────────────
useEffect(() => {
fetchPlan(planId);
setPolicyLoading(true);
api.get(`/admin/tier-policies/plans/${planId}/payment-policy`)
.then(({ data }) => {
const p = data.data;
if (p) {
setPolicy(p);
const rp = p.refund_policy ?? {};
setRefundAllowed(rp.allowed ?? true);
setRefundWindowValue(rp.window_value ?? 5);
setRefundWindowUnit(rp.window_unit ?? "minutes");
setRefundReasonReqd(rp.reason_required ?? false);
setPromoRules(p.promo_rules ?? []);
}
})
.catch(() => {})
.finally(() => setPolicyLoading(false));
api.get(`/admin/tiers/${planId}/prices`)
.then(({ data }) => setLocalizedPrices(data.data ?? []))
.catch(() => {});
}, [planId]);
// ─── Save ────────────────────────────────────────────────────────────────────
const handleSave = async () => {
setSaving(true);
try {
await api.put(`/admin/tier-policies/plans/${planId}/payment-policy`, {
refund_policy: {
allowed: refundAllowed,
window_value: Number(refundWindowValue),
window_unit: refundWindowUnit,
reason_required: refundReasonReqd,
},
promo_rules: promoRules,
});
toast.success("Payment policy saved.");
navigate("/admin/tiers/plans");
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not save payment policy.");
} finally {
setSaving(false);
}
};
// ─── Promo helpers ────────────────────────────────────────────────────────────
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;
}
const rule = {
code,
type: addForm.type,
value: Number(addForm.value),
...(addForm.type === "flat" && addForm.currency ? { currency: addForm.currency.trim().toUpperCase() } : {}),
...(addForm.type === "percent" && addForm.max_discount ? { max_discount: Number(addForm.max_discount) } : {}),
...(addForm.max_uses ? { max_uses: Number(addForm.max_uses) } : {}),
...(addForm.expires_at ? { expires_at: addForm.expires_at } : {}),
...(addForm.min_amount ? { min_amount: Number(addForm.min_amount) } : {}),
};
setPromoRules((prev) => [...prev, rule]);
setAddForm(EMPTY_PROMO);
setShowAdd(false);
};
const handleRemovePromo = (code) =>
setPromoRules((prev) => prev.filter((r) => r.code !== code));
// ─── Render ───────────────────────────────────────────────────────────────────
const isLoading = planLoading || policyLoading;
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={plan ? `Payment Policy — ${plan.label} - STARR` : "Payment Policy - STARR"} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: plan?.label ?? `Plan #${planId}`, to: `/admin/tiers/plans/${planId}/view` },
{ label: "Payment Policy" },
]} />
</div>
<div className="flex items-center gap-3 mb-6">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Payment Policy</h1>
<p className="text-sm text-muted-foreground capitalize">
{plan?.tier} — {plan?.label}
</p>
</div>
</div>
{isLoading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-32 w-full" />)}
</div>
) : (
<div className="space-y-5">
{/* ── Currency notice ───────────────────────────────────────── */}
<div className="w-full rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30 p-4">
<div className="flex items-start gap-3">
<div className="rounded-md bg-blue-100 dark:bg-blue-900/50 p-2 shrink-0">
<CreditCard className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-blue-900 dark:text-blue-200">
Plan base currency: <span className="font-mono">{plan?.currency ?? "USD"}</span>
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-0.5 leading-relaxed">
Flat promo code discounts are applied in <b>{plan?.currency ?? "USD"}</b>. If you need currency-specific pricing, configure overrides via{" "}
<b>Localized Prices</b> from the tier plan lists.
</p>
</div>
</div>
</div>
{/* ── Refund Policy ─────────────────────────────────────────── */}
<SectionCard
icon={ShieldCheck}
title="Refund Policy"
description="Controls whether and how long after purchase a user can request a refund."
>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<p className="text-sm font-medium">Allow Refunds</p>
<p className="text-xs text-muted-foreground">Users can request a refund within the window below.</p>
</div>
<Switch checked={refundAllowed} onCheckedChange={setRefundAllowed} />
</div>
{refundAllowed && (
<>
<div className="space-y-1.5">
<Label>Refund Window</Label>
<div className="flex gap-2">
<Input
type="number"
min={1}
className="w-28"
value={refundWindowValue}
onChange={(e) => setRefundWindowValue(e.target.value)}
placeholder="5"
/>
<Select value={refundWindowUnit} onValueChange={setRefundWindowUnit}>
<SelectTrigger className="w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WINDOW_UNITS.map((u) => (
<SelectItem key={u} value={u} className="capitalize">{u}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
Users have {refundWindowValue || "?"} {refundWindowUnit} from payment to request a refund.
</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div>
<p className="text-sm font-medium">Require Reason</p>
<p className="text-xs text-muted-foreground">User must provide a reason when requesting a refund.</p>
</div>
<Switch checked={refundReasonReqd} onCheckedChange={setRefundReasonReqd} />
</div>
</>
)}
</div>
</SectionCard>
{/* ── Promo Codes ───────────────────────────────────────────── */}
<SectionCard
icon={Tag}
title="Promo Codes"
description="Define discount codes users can apply at checkout. Flat reduces price by a fixed amount; percent reduces by a percentage."
>
{/* Localized price notice */}
{localizedPrices.length > 0 && (
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30 p-3">
<Globe className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="text-xs text-amber-800 dark:text-amber-300 space-y-0.5">
<p className="font-semibold">
This plan has {localizedPrices.length} localized price{localizedPrices.length > 1 ? "s" : ""} set
{" "}({localizedPrices.map((p) => p.currency).join(", ")}).
</p>
<p>
Flat discounts are deducted in the currency the user is being charged — not converted from <b>{plan?.currency ?? "USD"}</b>.
The Currency select below only shows available currencies for this plan.
Use <b>percent</b> for consistent savings across all currencies.
{" "}<a
href={`/admin/tiers/plans/${planId}/prices`}
className="inline-flex items-center gap-0.5 underline underline-offset-2 font-medium"
>
Manage prices <ExternalLink className="h-3 w-3" />
</a>
</p>
</div>
</div>
)}
{/* Existing rules */}
{promoRules.length > 0 ? (
<div className="space-y-2">
{promoRules.map((rule) => (
<div
key={rule.code}
className="flex items-center justify-between gap-3 rounded-lg border bg-muted/30 px-4 py-3"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<code className="text-sm font-semibold tracking-wide">{rule.code}</code>
<Badge variant="outline" className="text-xs capitalize shrink-0">
{rule.type}
</Badge>
<span className="text-sm text-muted-foreground shrink-0">
{rule.type === "flat"
? `${rule.currency ?? "USD"} ${Number(rule.value).toFixed(2)} off`
: `${rule.value}% off${rule.max_discount ? ` (max ${rule.max_discount})` : ""}`
}
</span>
{rule.max_uses && (
<span className="text-xs text-muted-foreground shrink-0">
· {rule.max_uses} uses max
</span>
)}
{rule.expires_at && (
<span className="text-xs text-muted-foreground shrink-0">
· expires {new Date(rule.expires_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })}
</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive shrink-0"
onClick={() => handleRemovePromo(rule.code)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
) : (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Tag className="size-4 shrink-0" />
No promo codes configured for this plan.
</div>
)}
{/* Add form */}
{showAdd ? (
<div className="rounded-lg border bg-muted/20 p-4 space-y-4">
<p className="text-sm font-medium">New Promo Code</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Code <span className="text-destructive">*</span></Label>
<Input
placeholder="e.g. SAVE10"
value={addForm.code}
onChange={(e) => setAddForm((f) => ({ ...f, code: e.target.value.toUpperCase() }))}
/>
</div>
<div className="space-y-1.5">
<Label>Type <span className="text-destructive">*</span></Label>
<Select value={addForm.type} onValueChange={(v) => setAddForm((f) => ({ ...f, type: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="flat">Flat (fixed amount off)</SelectItem>
<SelectItem value="percent">Percent (% off)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>
{addForm.type === "flat" ? "Amount Off" : "Percent Off"}{" "}
<span className="text-destructive">*</span>
</Label>
<Input
type="number"
step="0.01"
min="0.01"
placeholder={addForm.type === "flat" ? "10.00" : "20"}
value={addForm.value}
onChange={(e) => setAddForm((f) => ({ ...f, value: e.target.value }))}
/>
</div>
{addForm.type === "flat" && (
<div className="space-y-1.5">
<Label>Currency</Label>
<Select
value={addForm.currency}
onValueChange={(v) => setAddForm((f) => ({ ...f, currency: v }))}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value={plan?.currency ?? "USD"}>
{plan?.currency ?? "USD"} — Base price
</SelectItem>
{localizedPrices.map((p) => (
<SelectItem key={p.currency} value={p.currency}>
{p.currency} — Localized price
</SelectItem>
))}
</SelectContent>
</Select>
{localizedPrices.length === 0 && (
<p className="text-xs text-muted-foreground">
No localized prices set — only base currency available.
</p>
)}
</div>
)}
{addForm.type === "percent" && (
<div className="space-y-1.5">
<Label>Max Discount Cap</Label>
<Input
type="number"
step="0.01"
placeholder="50.00 (optional)"
value={addForm.max_discount}
onChange={(e) => setAddForm((f) => ({ ...f, max_discount: e.target.value }))}
/>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>Max Uses</Label>
<Input
type="number"
min="1"
placeholder="Unlimited"
value={addForm.max_uses}
onChange={(e) => setAddForm((f) => ({ ...f, max_uses: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label>Expires At</Label>
<DateTimePicker
value={addForm.expires_at || null}
onChange={(iso) => setAddForm((f) => ({ ...f, expires_at: iso ?? "" }))}
placeholder="No expiry"
/>
</div>
</div>
<div className="space-y-1.5">
<Label>Minimum Purchase Amount</Label>
<Input
type="number"
step="0.01"
placeholder="No minimum"
value={addForm.min_amount}
onChange={(e) => setAddForm((f) => ({ ...f, min_amount: e.target.value }))}
/>
</div>
<div className="flex gap-2 justify-end pt-1">
<Button variant="outline" size="sm" onClick={() => { setShowAdd(false); setAddForm(EMPTY_PROMO); }}>
Cancel
</Button>
<Button size="sm" onClick={handleAddPromo}>
<Plus className="h-4 w-4 mr-1" /> Add Code
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
onClick={() => {
setAddForm((f) => ({ ...f, currency: plan?.currency ?? "USD" }));
setShowAdd(true);
}}
>
<Plus className="h-4 w-4 mr-1" /> Add Promo Code
</Button>
)}
</SectionCard>
{/* ── Save ─────────────────────────────────────────────────── */}
<div className="flex justify-end gap-3 pt-1">
<Button variant="outline" onClick={() => navigate(-1)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Save Policy
</Button>
</div>
</div>
)}
</div>
</div>
</section>
);
}
+53 -2
View File
@@ -1,9 +1,10 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { House } from "lucide-react"; import { House, CreditCard, Tag, ShieldCheck, Globe } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import TierPlansTable from "../../components/tiers/TierPlansTable"; import TierPlansTable from "../../components/tiers/TierPlansTable";
import { useTiers } from "@/contexts/AdminTiersContext"; import { useTiers } from "@/contexts/AdminTiersContext";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { Badge } from "@/components/ui/badge";
export default function PlanList() { export default function PlanList() {
const { fetchPlans } = useTiers(); const { fetchPlans } = useTiers();
@@ -16,13 +17,63 @@ export default function PlanList() {
]; ];
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<PageMeta title="Plans - STARR" /> <PageMeta title="Plans - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6"> <div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
</div> </div>
<div className="w-full"> <div className="w-full">
<div className="w-full rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30 p-4 mb-4">
<div className="flex items-start gap-3">
<div className="rounded-md bg-blue-100 dark:bg-blue-900/50 p-2 shrink-0">
<Globe className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-blue-900 dark:text-blue-200">
Localized prices are configured per plan
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-0.5 leading-relaxed">
Each plan can have currency-specific prices for international users (e.g. CNY, EUR, JPY). Select <b>"Localized Prices"</b>. Users without a localized price fall back to the plan's base price. If no localized price is set, the price will be displayed in <b>US Dollar (USD)</b>.
</p>
<div className="flex items-center gap-4 mt-2">
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<Globe /> Localized Prices (per currency)
</Badge>
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<ShieldCheck /> Falls back to base price
</Badge>
</div>
</div>
</div>
</div>
<div className="w-full rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30 p-4 mb-4">
<div className="flex items-start gap-3">
<div className="rounded-md bg-blue-100 dark:bg-blue-900/50 p-2 shrink-0">
<CreditCard className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-blue-900 dark:text-blue-200">
Payment Policies are configured per plan
</p>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-0.5 leading-relaxed">
Each plan can have its own set of promo codes and refund window.
Open a plan's row actions and select <b>"Promo codes (flat or percent discount)"</b> to configure it.
</p>
<div className="flex items-center gap-4 mt-2">
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<Tag /> Promo codes (flat or percent discount)
</Badge>
<Badge variant="outline" className="text-blue-600 dark:text-blue-400">
<ShieldCheck /> Refund window (minutes / hours / days)
</Badge>
</div>
</div>
</div>
</div>
<TierPlansTable /> <TierPlansTable />
</div> </div>
</div> </div>
@@ -143,7 +143,7 @@ function TierCategoriesInner() {
{/* Delete confirmation dialog */} {/* Delete confirmation dialog */}
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}> <Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogContent> <DialogContent className="sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Tier Category</DialogTitle> <DialogTitle>Delete Tier Category</DialogTitle>
<DialogDescription> <DialogDescription>
+83 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, House, Pencil, Tag, BadgeCheck } from "lucide-react"; import { ArrowLeft, House, Pencil, Tag, BadgeCheck, BookOpen, Clock } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -47,6 +47,24 @@ function LoadingSkeleton() {
); );
} }
function formatDuration(days, unit) {
if (!days) return `${days} days`;
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
const multiplier = UNIT_TO_DAYS[unit] ?? 1;
const value = Math.round((days / multiplier) * 1000) / 1000;
const label = unit ?? 'day';
return `${value} ${label}${value !== 1 ? 's' : ''}`;
}
function formatCourseDuration(seconds = 0) {
if (!seconds) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h && m) return `${h}h ${m}m`;
if (h) return `${h}h`;
return `${m}m`;
}
export default function ViewPlan() { export default function ViewPlan() {
const navigate = useNavigate(); const navigate = useNavigate();
const { planId } = useParams(); const { planId } = useParams();
@@ -56,9 +74,17 @@ export default function ViewPlan() {
const [tierCategories, setTierCategories] = useState([]); const [tierCategories, setTierCategories] = useState([]);
const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]); const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]);
const [assignedCourses, setAssignedCourses] = useState([]);
const [coursesLoading, setCoursesLoading] = useState(false);
useEffect(() => { useEffect(() => {
fetchPlan(planId); fetchPlan(planId);
api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {}); api.get("/admin/tiers/categories").then(({ data }) => setTierCategories(data.data ?? [])).catch(() => {});
setCoursesLoading(true);
api.get(`/admin/tiers/${planId}/courses`)
.then(({ data }) => setAssignedCourses(data.data ?? []))
.catch(() => {})
.finally(() => setCoursesLoading(false));
}, [planId]); }, [planId]);
return ( return (
@@ -111,7 +137,7 @@ export default function ViewPlan() {
<InfoRow label="Tier"> <InfoRow label="Tier">
{(() => { const { cls, label } = resolveTierBadge(plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()} {(() => { const { cls, label } = resolveTierBadge(plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
</InfoRow> </InfoRow>
<InfoRow label="Duration">{plan.duration_days} days</InfoRow> <InfoRow label="Duration">{formatDuration(plan.duration_days, plan.duration_unit)}</InfoRow>
<InfoRow label="Price"> <InfoRow label="Price">
{plan.currency} {Number(plan.price).toFixed(2)} {plan.currency} {Number(plan.price).toFixed(2)}
</InfoRow> </InfoRow>
@@ -122,6 +148,61 @@ export default function ViewPlan() {
</Badge> </Badge>
</InfoRow> </InfoRow>
</div> </div>
{plan.description && (
<div className="flex flex-col gap-0.5 pt-1">
<span className="text-xs text-muted-foreground uppercase tracking-wide">Description</span>
<p className="text-sm">{plan.description}</p>
</div>
)}
</SectionCard>
<SectionCard icon={BookOpen} title="Assigned Courses">
{coursesLoading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : assignedCourses.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<BookOpen className="size-4 shrink-0" />
No courses assigned to this plan yet.
</div>
) : (
<div className="space-y-0 divide-y rounded-lg border overflow-hidden">
{assignedCourses.map((course) => (
<div key={course.course_id} className="flex items-center justify-between gap-3 px-4 py-3 bg-card hover:bg-muted/30 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className="h-8 w-8 rounded-md bg-muted flex items-center justify-center shrink-0">
<BookOpen className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium line-clamp-1">{course.title}</p>
{(course.course_code || course.level) && (
<div className="flex items-center gap-2 mt-0.5">
{course.course_code && (
<span className="text-xs text-muted-foreground">{course.course_code}</span>
)}
{course.level && (
<Badge variant="outline" className="text-xs capitalize h-4 px-1.5">{course.level}</Badge>
)}
</div>
)}
</div>
</div>
{formatCourseDuration(course.duration_seconds) && (
<span className="text-xs text-muted-foreground flex items-center gap-1 shrink-0">
<Clock className="size-3" />
{formatCourseDuration(course.duration_seconds)}
</span>
)}
</div>
))}
</div>
)}
{!coursesLoading && (
<p className="text-xs text-muted-foreground">
{assignedCourses.length} course{assignedCourses.length !== 1 ? "s" : ""} assigned
</p>
)}
</SectionCard> </SectionCard>
<SectionCard icon={BadgeCheck} title="Audit"> <SectionCard icon={BadgeCheck} title="Audit">
@@ -11,7 +11,7 @@ export default function ArchivedGroupList() {
] ]
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6 "> <div className="flex flex-col gap-2 my-6 ">
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
@@ -10,7 +10,7 @@ export default function GroupList() {
] ]
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6 "> <div className="flex flex-col gap-2 my-6 ">
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
@@ -11,7 +11,7 @@ export default function ArchivedUserList() {
] ]
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6 "> <div className="flex flex-col gap-2 my-6 ">
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
+1 -1
View File
@@ -10,7 +10,7 @@ export default function UserList() {
] ]
return ( return (
<section className="bg-muted/60 h-full"> <section className="bg-muted h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4"> <div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6 "> <div className="flex flex-col gap-2 my-6 ">
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
+9 -2
View File
@@ -90,6 +90,9 @@ import UserTierList from '../pages/tiers/UserTierList';
import PaymentList from '../pages/tiers/PaymentList'; import PaymentList from '../pages/tiers/PaymentList';
import ViewPayment from '../pages/tiers/ViewPayment'; import ViewPayment from '../pages/tiers/ViewPayment';
import TierCategories from '../pages/tiers/TierCategories'; import TierCategories from '../pages/tiers/TierCategories';
import ArchivedPlanList from '../pages/tiers/ArchivedPlanList';
import PaymentPolicy from '../pages/tiers/PaymentPolicy';
import LocalizedPrices from '../pages/tiers/LocalizedPrices';
import { AddTierCategory, EditTierCategory } from '../pages/tiers/EditTierCategory'; import { AddTierCategory, EditTierCategory } from '../pages/tiers/EditTierCategory';
import TaskSubmissions from '../pages/task_list/task/TaskCompletion' import TaskSubmissions from '../pages/task_list/task/TaskCompletion'
import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion' import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion'
@@ -252,10 +255,14 @@ export const AdminRoutes = {
children: [ children: [
{ index: true, element: <PlanList /> }, { index: true, element: <PlanList /> },
{ path: 'add', element: <AddPlan /> }, { path: 'add', element: <AddPlan /> },
{ path: ':planId/view', element: <ViewPlan /> }, { path: 'archived', element: <ArchivedPlanList /> },
{ path: ':planId/edit', element: <EditPlan /> }, { path: ':planId/view', element: <ViewPlan /> },
{ path: ':planId/edit', element: <EditPlan /> },
{ path: ':planId/payment-policy', element: <PaymentPolicy /> },
{ path: ':planId/prices', element: <LocalizedPrices /> },
] ]
}, },
{ path: 'prices', element: <LocalizedPrices /> },
{ path: 'system-badges', element: <SystemBadges /> }, { path: 'system-badges', element: <SystemBadges /> },
{ {
path: 'categories', path: 'categories',
+188 -139
View File
@@ -1,16 +1,29 @@
// components/QuizBlock.jsx // components/QuizBlock.jsx
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import { z } from "zod";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import {
ChevronLeft, ChevronRight, ChevronLeft, ChevronRight,
Circle, CheckCircle2, Circle, CheckCircle2,
Square, CheckSquare2, Square, CheckSquare2,
Clock, AlertTriangle, Info, Clock, AlertTriangle, Info, ArrowRight,
} from "lucide-react"; } from "lucide-react";
import { useClientNotifications } from "@/contexts/ClientNotificationContext"; import { useClientNotifications } from "@/contexts/ClientNotificationContext";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
// Zod schema: every question must have a non-empty answer before submitting.
function buildAnswerSchema(questions = []) {
const shape = {};
for (const q of questions) {
const key = String(q.question_id);
shape[key] = q.type === "multi_select"
? z.array(z.unknown()).min(1, "This question requires at least one selection.")
: z.union([z.string(), z.number()]).refine((v) => v !== "" && v != null, "This question requires an answer.");
}
return z.object(shape);
}
function QuizSkeleton() { function QuizSkeleton() {
return ( return (
<div className="max-w-2xl mx-auto space-y-5"> <div className="max-w-2xl mx-auto space-y-5">
@@ -50,7 +63,7 @@ function isQuestionAnswered(answer) {
* onActiveChange — (isActive: boolean) => void — fires when session starts/ends * onActiveChange — (isActive: boolean) => void — fires when session starts/ends
* label — "Quiz" or "Assessment" * label — "Quiz" or "Assessment"
*/ */
const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession, onSubmit, onRetake, onActiveChange, label = "Quiz" }) => { const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession, onSubmit, onRetake, onActiveChange, onNextContent, nextLabel, label = "Quiz" }) => {
const { fmtDateTime } = useDateFormat(); const { fmtDateTime } = useDateFormat();
const questions = quiz?.questions ?? []; const questions = quiz?.questions ?? [];
const total = questions.length; const total = questions.length;
@@ -63,6 +76,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [reviewAttempted, setReviewAttempted] = useState(false); const [reviewAttempted, setReviewAttempted] = useState(false);
const [submitError, setSubmitError] = useState(null);
// ── Timer state ─────────────────────────────────────────────────────────── // ── Timer state ───────────────────────────────────────────────────────────
const [remainingSeconds, setRemainingSeconds] = useState(null); const [remainingSeconds, setRemainingSeconds] = useState(null);
@@ -216,11 +230,25 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
}; };
const handleSubmit = useCallback(async () => { const handleSubmit = useCallback(async () => {
// Zod: all questions must be answered before submission is allowed
const schema = buildAnswerSchema(questions);
const stringifiedAnswers = Object.fromEntries(
Object.entries(answers).map(([k, v]) => [String(k), v])
);
const parsed = schema.safeParse(stringifiedAnswers);
if (!parsed.success) {
const unanswered = questions.filter(
(q) => !isQuestionAnswered(answers[q.question_id])
).length;
setSubmitError(`Answer all questions before submitting — ${unanswered} still unanswered.`);
return;
}
setSubmitError(null);
setSubmitting(true); setSubmitting(true);
const res = await onSubmit?.(answers, sessionRef.current.sessionId); const res = await onSubmit?.(answers, sessionRef.current.sessionId);
setSubmitting(false); setSubmitting(false);
if (res) { setResult(res); setStage("result"); } if (res) { setResult(res); setStage("result"); }
}, [answers, onSubmit]); }, [answers, questions, onSubmit]);
const handleOptionClick = (optionId) => { const handleOptionClick = (optionId) => {
const question = questions[currentIndex]; const question = questions[currentIndex];
@@ -239,8 +267,6 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
const handleGoToReview = () => { const handleGoToReview = () => {
setReviewAttempted(true); setReviewAttempted(true);
const allAnswered = questions.every(q => isQuestionAnswered(answers[q.question_id]));
if (!allAnswered) return;
setStage("review"); setStage("review");
}; };
@@ -382,7 +408,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
<ul className="space-y-2.5 text-sm text-foreground/80"> <ul className="space-y-2.5 text-sm text-foreground/80">
{hasTimeLimit && ( {hasTimeLimit && (
<li className="flex items-start gap-2.5"> <li className="flex items-start gap-2.5">
<span className="mt-2 size-1.5 rounded-full bg-foreground shrink-0" /> <span className="mt-2 size-1.5 rounded-full bg-primary shrink-0" />
<span> <span>
The timer starts the moment you begin and{" "} The timer starts the moment you begin and{" "}
<strong>cannot be paused</strong>. Your assessment auto-submits when time runs out. <strong>cannot be paused</strong>. Your assessment auto-submits when time runs out.
@@ -390,17 +416,17 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
</li> </li>
)} )}
<li className="flex items-start gap-2.5"> <li className="flex items-start gap-2.5">
<span className="mt-2 size-1.5 rounded-full bg-foreground shrink-0" /> <span className="mt-2 size-1.5 rounded-full bg-primary shrink-0" />
<span>Answer each question before moving to the next. You can return to any answered question to change your answer.</span> <span>You can skip questions and return to them later. All questions must be answered before you can submit.</span>
</li> </li>
<li className="flex items-start gap-2.5"> <li className="flex items-start gap-2.5">
<span className="mt-2 size-1.5 rounded-full bg-foreground shrink-0" /> <span className="mt-2 size-1.5 rounded-full bg-primary shrink-0" />
<span> <span>
Your progress is <strong>saved automatically</strong> — you can safely resume if you lose connection. Your progress is <strong>saved automatically</strong> — you can safely resume if you lose connection.
</span> </span>
</li> </li>
<li className="flex items-start gap-2.5"> <li className="flex items-start gap-2.5">
<span className="mt-2 size-1.5 rounded-full bg-foreground shrink-0" /> <span className="mt-2 size-1.5 rounded-full bg-primary shrink-0" />
<span>Review all answers on the summary screen before final submission.</span> <span>Review all answers on the summary screen before final submission.</span>
</li> </li>
</ul> </ul>
@@ -501,6 +527,20 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
<p className="text-xs text-muted-foreground">Attempt #{result.attempt_number}</p> <p className="text-xs text-muted-foreground">Attempt #{result.attempt_number}</p>
)} )}
</div> </div>
{result.passed && onNextContent && nextLabel && (
<div
onClick={onNextContent}
className="flex items-center gap-3 bg-card border rounded-xl px-4 py-3 shadow-sm hover:bg-muted transition-colors text-sm font-medium cursor-pointer select-none"
>
<div className="flex flex-col items-start flex-1">
<span className="text-xs text-muted-foreground font-normal">Up next</span>
<span>{nextLabel}</span>
</div>
<ArrowRight className="size-4 text-muted-foreground shrink-0" />
</div>
)}
{!result.passed && ( {!result.passed && (
<div className="flex justify-center"> <div className="flex justify-center">
<Button variant="outline" onClick={handleRetake}>Retake {label}</Button> <Button variant="outline" onClick={handleRetake}>Retake {label}</Button>
@@ -559,7 +599,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
{/* All questions grid */} {/* All questions grid */}
<div className="rounded-xl border bg-card p-5 space-y-4"> <div className="rounded-xl border bg-card p-5 space-y-4">
<p className="text-sm font-semibold">All questions</p> <p className="text-sm font-semibold">All questions</p>
<div className="grid grid-cols-8 gap-2"> <div className="grid xs:grid-cols-8 lg:grid-cols-12 gap-2">
{questions.map((q, i) => { {questions.map((q, i) => {
const answered = isQuestionAnswered(answers[q.question_id]); const answered = isQuestionAnswered(answers[q.question_id]);
return ( return (
@@ -569,7 +609,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
onClick={() => { setCurrentIndex(i); setStage("taking"); }} onClick={() => { setCurrentIndex(i); setStage("taking"); }}
className={`aspect-square rounded-lg text-sm font-medium transition-colors className={`aspect-square rounded-lg text-sm font-medium transition-colors
${answered ${answered
? "bg-foreground text-background hover:opacity-80" ? "bg-primary text-background hover:opacity-80"
: "border-2 border-amber-400 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10" : "border-2 border-amber-400 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10"
}`} }`}
> >
@@ -580,7 +620,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
</div> </div>
<div className="flex gap-4 text-xs text-muted-foreground"> <div className="flex gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm bg-foreground inline-block" /> Answered <span className="size-3 rounded-sm bg-primary inline-block" /> Answered
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm border-2 border-amber-400 inline-block" /> Unanswered <span className="size-3 rounded-sm border-2 border-amber-400 inline-block" /> Unanswered
@@ -588,14 +628,21 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
</div> </div>
</div> </div>
<div className="flex items-center justify-between"> <div className="space-y-2">
<Button variant="outline" onClick={() => setStage("taking")} disabled={submitting}> <div className="flex items-center justify-between">
<ChevronLeft className="size-4" /> <Button variant="outline" onClick={() => setStage("taking")} disabled={submitting}>
Back to assessment <ChevronLeft className="size-4" />
</Button> Back to assessment
<Button onClick={handleSubmit} disabled={submitting}> </Button>
{submitting ? "Submitting…" : "Submit assessment"} <Button onClick={handleSubmit} disabled={submitting}>
</Button> {submitting ? "Submitting…" : "Submit assessment"}
</Button>
</div>
{submitError && (
<p className="text-xs text-red-600 dark:text-red-400 text-right flex items-center justify-end gap-1">
<AlertTriangle className="size-3 shrink-0" /> {submitError}
</p>
)}
</div> </div>
</div> </div>
); );
@@ -622,7 +669,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
onClick={() => { setCurrentIndex(i); setStage("taking"); }} onClick={() => { setCurrentIndex(i); setStage("taking"); }}
className={`size-9 rounded-full text-sm font-semibold transition-colors className={`size-9 rounded-full text-sm font-semibold transition-colors
${answered ${answered
? "bg-foreground text-background hover:opacity-80" ? "bg-primary text-background hover:opacity-80"
: "bg-muted text-muted-foreground border border-border hover:bg-muted/80" : "bg-muted text-muted-foreground border border-border hover:bg-muted/80"
}`} }`}
> >
@@ -670,7 +717,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
onClick={() => { setCurrentIndex(i); setStage("taking"); }} onClick={() => { setCurrentIndex(i); setStage("taking"); }}
className="w-full flex items-center gap-4 rounded-xl border bg-card p-4 text-left hover:bg-muted/30 transition-colors" className="w-full flex items-center gap-4 rounded-xl border bg-card p-4 text-left hover:bg-muted/30 transition-colors"
> >
<span className="shrink-0 size-8 rounded-full bg-foreground text-background text-sm font-semibold flex items-center justify-center"> <span className="shrink-0 size-8 rounded-full bg-primary text-background text-sm font-semibold flex items-center justify-center">
{i + 1} {i + 1}
</span> </span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -691,14 +738,21 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
})} })}
</div> </div>
<div className="flex items-center justify-between"> <div className="space-y-2">
<Button variant="outline" onClick={() => setStage("taking")} disabled={submitting}> <div className="flex items-center justify-between">
<ChevronLeft className="size-4" /> <Button variant="outline" onClick={() => setStage("taking")} disabled={submitting}>
Back to questions <ChevronLeft className="size-4" />
</Button> Back to questions
<Button onClick={handleSubmit} disabled={submitting}> </Button>
{submitting ? "Submitting…" : "Submit quiz"} <Button onClick={handleSubmit} disabled={submitting}>
</Button> {submitting ? "Submitting…" : "Submit quiz"}
</Button>
</div>
{submitError && (
<p className="text-xs text-red-600 dark:text-red-400 text-right flex items-center justify-end gap-1">
<AlertTriangle className="size-3 shrink-0" /> {submitError}
</p>
)}
</div> </div>
</div> </div>
); );
@@ -776,9 +830,100 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
</div> </div>
)} )}
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-5 items-start"> <div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-4 items-start">
{/* ── Left: question ── */} {/* ── Right: sidebar — order-first on mobile so timer/progress sit above the question ── */}
<div className="space-y-4"> <div className="space-y-2 order-first lg:order-last">
{/* Timer */}
{remainingSeconds !== null && (
<div className={`rounded-xl flex items-center justify-between gap-3 px-4 py-3 lg:block lg:p-4 ${
timeExpired
? "bg-red-600 text-white"
: remainingSeconds <= 60
? "bg-red-700 text-white"
: "bg-primary text-background"
}`}>
<p className="text-xs uppercase tracking-widest opacity-60">Time Remaining</p>
<p className="text-2xl lg:text-4xl font-bold font-mono tabular-nums lg:mt-1">
{timeExpired ? "00:00" : formatTime(remainingSeconds)}
</p>
</div>
)}
{/* Progress panel */}
<div className="rounded-xl border bg-card p-3 lg:p-4 space-y-2 lg:space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Progress</span>
<span className="text-muted-foreground">{answeredCount} / {total} answered</span>
</div>
{/* Number grid — scrollable after ~30 questions (3 rows on mobile, 4 on desktop) */}
<div className="overflow-y-auto max-h-24 lg:max-h-40">
<div className="grid grid-cols-10 lg:grid-cols-8 gap-1 lg:gap-1.5">
{questions.map((q, i) => {
const ans = answers[q.question_id];
const answered = isQuestionAnswered(ans);
const isCurrent = i === currentIndex;
return (
<button
key={q.question_id}
type="button"
onClick={() => setCurrentIndex(i)}
disabled={timeExpired && submitting}
className={`aspect-square rounded text-xs font-medium transition-colors
${isCurrent
? "border-2 border-foreground bg-background text-foreground"
: answered
? "bg-primary text-background hover:opacity-80"
: "border border-border bg-background text-muted-foreground hover:bg-muted"
}`}
>
{i + 1}
</button>
);
})}
</div>
</div>
{/* Legend — desktop only */}
<div className="hidden lg:flex flex-col gap-1.5 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm bg-primary inline-block shrink-0" />
Answered
</span>
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm border border-border inline-block shrink-0" />
Not answered
</span>
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm border-2 border-foreground inline-block shrink-0" />
Current
</span>
</div>
{/* Auto-save indicator */}
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 rounded-lg bg-green-500/5 border border-green-500/20 px-3 py-2">
<CheckCircle2 className="size-3.5 shrink-0" />
All answers saved automatically
</div>
{/* Review button */}
<Button
className="w-full"
onClick={handleGoToReview}
disabled={submitting}
>
Review &amp; submit
</Button>
{reviewAttempted && unansweredCount > 0 && (
<p className="text-xs text-red-600 dark:text-red-400 text-center">
Answer all {total} questions first — {unansweredCount} remaining.
</p>
)}
</div>
</div>
{/* ── Left: question — order-last on mobile so it appears below the compact sidebar ── */}
<div className="space-y-4 order-last lg:order-first">
{/* Question header */} {/* Question header */}
<div className="flex items-center gap-2.5 text-sm"> <div className="flex items-center gap-2.5 text-sm">
<span className="text-muted-foreground">Question {currentIndex + 1} of {total}</span> <span className="text-muted-foreground">Question {currentIndex + 1} of {total}</span>
@@ -823,102 +968,12 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
Previous Previous
</Button> </Button>
<Button onClick={handleNext} disabled={submitting || !isCurrentAnswered}> <Button onClick={handleNext} disabled={submitting}>
{submitting ? "Submitting…" : isLast ? "Review & submit" : "Next"} {submitting ? "Submitting…" : isLast ? "Review & submit" : "Next"}
{!submitting && <ChevronRight className="size-4" />} {!submitting && <ChevronRight className="size-4" />}
</Button> </Button>
</div> </div>
</div> </div>
{/* ── Right: sidebar ── */}
<div className="space-y-3">
{/* Timer */}
{remainingSeconds !== null && (
<div className={`rounded-xl p-4 ${
timeExpired
? "bg-red-600 text-white"
: remainingSeconds <= 60
? "bg-red-700 text-white"
: "bg-foreground text-background"
}`}>
<p className="text-xs uppercase tracking-widest opacity-60">Time Remaining</p>
<p className="text-4xl font-bold font-mono mt-1 tabular-nums">
{timeExpired ? "00:00" : formatTime(remainingSeconds)}
</p>
</div>
)}
{/* Progress panel */}
<div className="rounded-xl border bg-card p-4 space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Progress</span>
<span className="text-muted-foreground">{answeredCount} / {total} answered</span>
</div>
{/* Number grid */}
<div className="grid grid-cols-8 gap-1.5">
{questions.map((q, i) => {
const ans = answers[q.question_id];
const answered = isQuestionAnswered(ans);
const isCurrent = i === currentIndex;
const canJump = answered || isCurrent;
return (
<button
key={q.question_id}
type="button"
onClick={() => canJump && setCurrentIndex(i)}
disabled={(timeExpired && submitting)}
className={`aspect-square rounded text-xs font-medium transition-colors
${isCurrent
? "border-2 border-foreground bg-background text-foreground"
: answered
? "bg-foreground text-background hover:opacity-80"
: "border border-border bg-background text-muted-foreground cursor-default"
}`}
>
{i + 1}
</button>
);
})}
</div>
{/* Legend */}
<div className="flex flex-col gap-1.5 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm bg-foreground inline-block shrink-0" />
Answered
</span>
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm border border-border inline-block shrink-0" />
Not answered
</span>
<span className="flex items-center gap-1.5">
<span className="size-3 rounded-sm border-2 border-foreground inline-block shrink-0" />
Current
</span>
</div>
{/* Auto-save indicator */}
<div className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400 rounded-lg bg-green-500/5 border border-green-500/20 px-3 py-2">
<CheckCircle2 className="size-3.5 shrink-0" />
All answers saved automatically
</div>
{/* Review button */}
<Button
className="w-full"
onClick={handleGoToReview}
disabled={submitting}
>
Review &amp; submit
</Button>
{reviewAttempted && unansweredCount > 0 && (
<p className="text-xs text-red-600 dark:text-red-400 text-center">
Answer all {total} questions first — {unansweredCount} remaining.
</p>
)}
</div>
</div>
</div> </div>
</div> </div>
); );
@@ -941,25 +996,24 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
</div> </div>
</div> </div>
{/* Question number pills */} {/* Question number pills — all freely clickable; skipped questions show as muted */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{questions.map((q, i) => { {questions.map((q, i) => {
const ans = answers[q.question_id]; const ans = answers[q.question_id];
const answered = isQuestionAnswered(ans); const answered = isQuestionAnswered(ans);
const isCurrent = i === currentIndex; const isCurrent = i === currentIndex;
const canJump = answered || isCurrent;
return ( return (
<button <button
key={q.question_id} key={q.question_id}
type="button" type="button"
onClick={() => canJump && setCurrentIndex(i)} onClick={() => setCurrentIndex(i)}
disabled={timeExpired && submitting} disabled={timeExpired && submitting}
className={`size-9 rounded-full text-sm font-semibold transition-colors className={`size-9 rounded-full text-sm font-semibold transition-colors
${isCurrent ${isCurrent
? "border-2 border-primary bg-background text-foreground" ? "border-2 border-primary bg-background text-foreground"
: answered : answered
? "bg-foreground text-background hover:opacity-80" ? "bg-primary text-background hover:opacity-80"
: "bg-muted text-muted-foreground border border-border cursor-default" : "bg-muted text-muted-foreground border border-border hover:bg-muted/60"
}`} }`}
> >
{i + 1} {i + 1}
@@ -1025,16 +1079,11 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
Previous Previous
</Button> </Button>
<Button onClick={handleNext} disabled={submitting || !isCurrentAnswered}> <Button onClick={handleNext} disabled={submitting}>
{submitting ? "Submitting…" : isLast ? "Review answers" : "Next"} {submitting ? "Submitting…" : isLast ? "Review answers" : "Next"}
{!submitting && <ChevronRight className="size-4" />} {!submitting && <ChevronRight className="size-4" />}
</Button> </Button>
</div> </div>
{reviewAttempted && unansweredCount > 0 && (
<p className="text-xs text-red-600 dark:text-red-400 text-right">
Answer all {total} questions first — {unansweredCount} remaining.
</p>
)}
</div> </div>
</div> </div>
); );
+53 -13
View File
@@ -19,15 +19,18 @@ import {
User, Settings, LogOut, SquareArrowOutUpRight, TableOfContents, User, Settings, LogOut, SquareArrowOutUpRight, TableOfContents,
CircleQuestionMark, Gift, Zap, Download, Copy, Check, CircleQuestionMark, Gift, Zap, Download, Copy, Check,
} from "lucide-react" } from "lucide-react"
import * as LucideIcons from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Toaster } from "sonner" import { Toaster } from "sonner"
import { useAuth } from "@/contexts/AuthContext" import { useAuth } from "@/contexts/AuthContext"
import api from "@/utils/api.util"
import { ClientProvider } from "@/contexts/provider/ClientProvider" import { ClientProvider } from "@/contexts/provider/ClientProvider"
import { useProfile } from "@/contexts/ProfileProvider" import { useProfile } from "@/contexts/ProfileProvider"
import { useClientTiers } from "@/contexts/ClientTiersProvider" import { useClientTiers } from "@/contexts/ClientTiersProvider"
import { resolveTierBadge } from "@/utils/tierBadge.util"
import { useGroup } from "@/contexts/ClientGroupContext" import { useGroup } from "@/contexts/ClientGroupContext"
import { useEffect, useState } from "react" import { useEffect, useRef, useState } from "react"
import { AVATAR_COLORS } from "@/data/profile.data" import { AVATAR_COLORS } from "@/data/profile.data"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import ClientNotificationBell from "@/components/generic/ClientNotificationBell" import ClientNotificationBell from "@/components/generic/ClientNotificationBell"
@@ -141,15 +144,19 @@ function ClientNav() {
// Background fetches only — nav rendering never waits on these // Background fetches only — nav rendering never waits on these
const { achievements, getAchievements } = useProfile() const { achievements, getAchievements } = useProfile()
const { myTier, getMyTier, getTierCategories } = useClientTiers() const { myTier, tierMap, tierCategories, getMyTier, getTierCategories } = useClientTiers()
const [referOpen, setReferOpen] = useState(false) const [referOpen, setReferOpen] = useState(false)
const [badgeImgUrl, setBadgeImgUrl] = useState(null)
// Caches the resolved stream URL per asset_id — avoids re-hitting /media/token
// for the same asset on re-renders. Token TTL is 4h so safe within a session.
const badgeTokenCache = useRef({})
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
if (achievements.length === 0) getAchievements(); if (achievements.length === 0) getAchievements();
if (!myTier) getMyTier(); if (!myTier) getMyTier();
getTierCategories(); if (tierCategories.length === 0) getTierCategories();
}, [user]); }, [user]);
// ── Derive directly from auth user — same pattern as admin UserMenu ────── // ── Derive directly from auth user — same pattern as admin UserMenu ──────
@@ -161,14 +168,43 @@ function ClientNav() {
const email = user?.email ?? "" const email = user?.email ?? ""
const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground' const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
const TIER_NAV_BADGE = { const tierSlug = myTier?.status === 'active' ? (myTier.tier ?? 'free') : 'free'
free: { label: 'Free', className: '' }, const tierBadge = resolveTierBadge(tierSlug, tierMap)
premium: { label: 'Premium Access', className: 'bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0' }, const TierIcon = LucideIcons[tierMap[tierSlug]?.badge_icon] ?? null
exclusive: { label: 'Exclusive Access', className: 'bg-gradient-to-r from-rose-500 to-red-600 text-white border-0' },
} // Derive primitive deps — effect only fires when the actual asset changes,
const tierBadge = myTier?.status === 'active' // not on every tierMap/tierSlug reference churn.
? (TIER_NAV_BADGE[myTier.tier] ?? TIER_NAV_BADGE.free) const badgeAsset = tierMap[tierSlug]?.badgeAsset ?? null
: TIER_NAV_BADGE.free const badgeAssetId = badgeAsset?.asset_id ?? null
const badgeProvider = badgeAsset?.storage_provider ?? null
const badgeFileUrl = badgeAsset?.file_url ?? null
useEffect(() => {
if (!badgeAssetId) { setBadgeImgUrl(null); return }
// Non-S3: use raw URL directly — no token needed
if (badgeProvider !== 's3') { setBadgeImgUrl(badgeFileUrl); return }
// Ref cache hit — reuse the URL without another POST
if (badgeTokenCache.current[badgeAssetId]) {
setBadgeImgUrl(badgeTokenCache.current[badgeAssetId])
return
}
let cancelled = false
const base = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '')
api.post('/client/media/token', { asset_id: badgeAssetId })
.then(({ data }) => {
if (cancelled) return
const url = `${base}/client/media/stream/${data?.data?.token}`
badgeTokenCache.current[badgeAssetId] = url
setBadgeImgUrl(url)
})
.catch(() => { if (!cancelled) setBadgeImgUrl(null) })
return () => { cancelled = true }
}, [badgeAssetId, badgeProvider, badgeFileUrl])
const initials = given && last const initials = given && last
? (given[0] + last[0]).toUpperCase() ? (given[0] + last[0]).toUpperCase()
@@ -211,9 +247,13 @@ function ClientNav() {
</div> </div>
<div id="name" className="lg:-ml-1 font-medium text-sm flex items-center gap-2"> <div id="name" className="lg:-ml-1 font-medium text-sm flex items-center gap-2">
{tierBadge && ( {tierBadge && (
<Badge className={`xs:hidden md:block ${tierBadge.className}`}> <div className={`xs:hidden md:inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${tierBadge.cls}`}>
{badgeImgUrl
? <img src={badgeImgUrl} className="size-3.5 rounded-full object-cover" />
: TierIcon && <TierIcon className="size-3" />
}
{tierBadge.label} {tierBadge.label}
</Badge> </div>
)} )}
</div> </div>
</div> </div>
+102 -20
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { KeyRound, CreditCard, Mail, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2 } from "lucide-react"; import { KeyRound, CreditCard, Mail, ChevronRight, Eye, EyeOff, ShieldAlert, Trash2, Globe } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -9,6 +9,7 @@ import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { ScrollArea } from "@/components/ui/scroll-area";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -19,10 +20,12 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useProfile } from "@/contexts/ProfileProvider"; import { useProfile } from "@/contexts/ProfileProvider";
import { useClientTiers } from "@/contexts/ClientTiersProvider"; import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import { useCurrencyPreference } from "@/contexts/CurrencyPreferenceContext";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -190,7 +193,7 @@ function SubscriptionSection() {
) : ( ) : (
<div className="rounded-lg border overflow-hidden"> <div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted/50"> <thead className="bg-muted/50 sticky top-0 z-10">
<tr> <tr>
<th className="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground">Date</th> <th className="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground">Date</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground">Plan</th> <th className="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground">Plan</th>
@@ -198,25 +201,29 @@ function SubscriptionSection() {
<th className="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground">Status</th> <th className="text-left px-4 py-2.5 text-xs font-medium text-muted-foreground">Status</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y">
{payments.map((p) => (
<tr key={p.payment_id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 text-muted-foreground">
{fmtDate(p.createdAt)}
</td>
<td className="px-4 py-3 capitalize">{p.plan?.tier ?? "—"}</td>
<td className="px-4 py-3">
{p.currency} {fmtNumber(p.amount ?? 0)}
</td>
<td className="px-4 py-3">
<Badge variant={p.status === "completed" ? "outline" : ""} className="capitalize text-xs">
{p.status}
</Badge>
</td>
</tr>
))}
</tbody>
</table> </table>
<ScrollArea className="h-[192px]">
<table className="w-full text-sm">
<tbody className="divide-y">
{payments.map((p) => (
<tr key={p.payment_id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 text-muted-foreground">
{fmtDate(p.createdAt)}
</td>
<td className="px-4 py-3 capitalize">{p.plan?.tier ?? "—"}</td>
<td className="px-4 py-3">
{p.currency} {fmtNumber(p.amount ?? 0)}
</td>
<td className="px-4 py-3">
<Badge variant={p.status === "completed" ? "outline" : ""} className="capitalize text-xs">
{p.status}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</ScrollArea>
</div> </div>
)} )}
</div> </div>
@@ -278,6 +285,77 @@ function NewsletterSection() {
); );
} }
// ─── Currency Preference ─────────────────────────────────────────────────────
function CurrencySection() {
const { profile, getProfile } = useProfile();
const { setCurrency } = useCurrencyPreference();
const [currencies, setCurrencies] = useState([]);
const [localValue, setLocalValue] = useState("USD");
const [saving, setSaving] = useState(false);
useEffect(() => {
getProfile();
api.get("/client/tiers/currencies")
.then(({ data }) => setCurrencies(data.data ?? []))
.catch(() => {});
}, []);
// Sync local value when profile loads or changes
useEffect(() => {
if (profile?.preferred_currency) setLocalValue(profile.preferred_currency);
}, [profile?.preferred_currency]);
const savedValue = profile?.preferred_currency ?? "USD";
const isDirty = localValue !== savedValue;
const handleSave = async () => {
setSaving(true);
try {
await api.patch("/client/profile/currency", { currency: localValue });
setCurrency(localValue);
toast.success("Currency preference saved.");
} catch (err) {
toast.error(err?.response?.data?.message ?? "Could not save preference.");
setLocalValue(savedValue); // revert on error
} finally {
setSaving(false);
}
};
return (
<div className="space-y-3">
<div className="space-y-1.5 max-w-xs">
<Label>Preferred currency</Label>
{!profile ? (
<Skeleton className="h-9 w-full" />
) : (
<Select value={localValue} onValueChange={setLocalValue} disabled={saving}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{currencies.map((c) => (
<SelectItem key={c.code} value={c.code}>
{c.code} — {c.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<p className="text-xs text-muted-foreground">
Plans without localized prices always display in USD regardless of this setting.
</p>
</div>
{isDirty && (
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? "Saving…" : "Save"}
</Button>
)}
</div>
);
}
// ─── Delete Account ─────────────────────────────────────────────────────────── // ─── Delete Account ───────────────────────────────────────────────────────────
function DeleteAccountSection({ logout }) { function DeleteAccountSection({ logout }) {
@@ -368,6 +446,10 @@ export default function AccountSettings() {
<NewsletterSection /> <NewsletterSection />
</Section> </Section>
<Section icon={Globe} title="Currency Preference" description="Set the currency used to display plan prices across the platform.">
<CurrencySection />
</Section>
<Section icon={Trash2} title="Danger Zone" description="Irreversible account actions."> <Section icon={Trash2} title="Danger Zone" description="Irreversible account actions.">
<DeleteAccountSection logout={logout} /> <DeleteAccountSection logout={logout} />
</Section> </Section>
+136 -43
View File
@@ -15,16 +15,22 @@ import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { import {
ArrowLeft, BookOpen, CalendarDays, Check, ArrowLeft, BookOpen, CalendarDays, Check,
House, Loader2, ShieldCheck, Tag, Zap, LockIcon, Globe, House, Loader2, ShieldCheck, Tag, Zap, LockIcon,
} from "lucide-react"; } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import { useCurrency } from "@/hooks/useCurrency";
import { useCurrencyPreference } from "@/contexts/CurrencyPreferenceContext";
import api from "@/utils/api.util";
function formatDuration(days) { function formatDuration(days, unit) {
if (!days) return "Lifetime"; if (!days) return "Lifetime";
if (days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""}`; const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
if (days % 30 === 0) return `${days / 30} month${days / 30 > 1 ? "s" : ""}`; const multiplier = UNIT_TO_DAYS[unit] ?? 1;
return `${days} days`; const value = Math.round((days / multiplier) * 1000) / 1000;
const label = unit ?? "day";
return `${value} ${label}${value !== 1 ? "s" : ""}`;
} }
function formatCourseDuration(seconds = 0) { function formatCourseDuration(seconds = 0) {
@@ -63,6 +69,8 @@ const Checkout = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { fmtCurrency } = useDateFormat(); const { fmtCurrency } = useDateFormat();
const { fmtPlanPrice, resolvePlanPrice } = useCurrency();
const { currency, setCurrency } = useCurrencyPreference();
const planId = searchParams.get("plan_id"); const planId = searchParams.get("plan_id");
const returnToken = searchParams.get("token"); const returnToken = searchParams.get("token");
@@ -74,24 +82,44 @@ const Checkout = () => {
plans, plansLoading, plans, plansLoading,
myTier, tierLoading, myTier, tierLoading,
checkoutLoading, checkoutLoading,
promoLoading,
getPlans, getMyTier, getPlans, getMyTier,
validatePromo,
createOrder, captureOrder, cancelOrder, createOrder, captureOrder, cancelOrder,
} = useClientTiers(); } = useClientTiers();
const [promoCode, setPromoCode] = useState(""); const [promoCode, setPromoCode] = useState("");
const [isPromoApplied, setIsPromoApplied] = useState(false); const [promoResult, setPromoResult] = useState(null); // { valid, code, type, value, discount }
const [capturing, setCapturing] = useState(false); const [capturing, setCapturing] = useState(false);
const capturingRef = useRef(false); const capturingRef = useRef(false);
useEffect(() => { useEffect(() => {
getProfile(); getProfile();
}, []); }, []);
// Seed currency from the user's stored preference when profile loads
useEffect(() => {
if (profile?.preferred_currency && profile.preferred_currency !== currency) {
setCurrency(profile.preferred_currency);
}
}, [profile?.preferred_currency]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { useEffect(() => {
getMyTier(); getMyTier();
if (!plans.length) getPlans(); if (!plans.length) getPlans();
}, [getMyTier, getPlans, plans.length]); }, [getMyTier, getPlans, plans.length]);
const handleCurrencyChange = async (newCurrency) => {
setCurrency(newCurrency);
setPromoResult(null);
setPromoCode("");
try {
await api.patch("/client/profile/currency", { currency: newCurrency });
} catch {
// silent — context + localStorage already updated
}
};
const plan = useMemo( const plan = useMemo(
() => plans.find((p) => String(p.plan_id) === String(planId)) ?? null, () => plans.find((p) => String(p.plan_id) === String(planId)) ?? null,
[plans, planId] [plans, planId]
@@ -130,10 +158,11 @@ const Checkout = () => {
const isCurrent = plan && myTier?.tier === plan.tier && myTier?.status === "active"; const isCurrent = plan && myTier?.tier === plan.tier && myTier?.status === "active";
const style = TIER_STYLES[plan?.tier] ?? TIER_STYLES.free; const style = TIER_STYLES[plan?.tier] ?? TIER_STYLES.free;
const Icon = style.icon; const Icon = style.icon;
const subtotal = Number(plan?.price) || 0; const { price: effectivePrice, currency: effectiveCurrency } = resolvePlanPrice(plan ?? {});
const discount = isPromoApplied ? Math.min(10, subtotal) : 0; const subtotal = plan ? effectivePrice : 0;
const discount = promoResult?.discount ?? 0;
const total = Math.max(subtotal - discount, 0); const total = Math.max(subtotal - discount, 0);
const duration = formatDuration(plan?.duration_days); const duration = formatDuration(plan?.duration_days, plan?.duration_unit);
const breadcrumbItems = [ const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/" }, { label: "Home", icon: <House className="size-4" />, to: "/" },
@@ -141,19 +170,29 @@ const Checkout = () => {
{ label: "Checkout" }, { label: "Checkout" },
]; ];
const handleApplyPromo = () => { const handleApplyPromo = async () => {
if (promoCode.trim().toUpperCase() === "PHIL10") { if (!plan) return;
setIsPromoApplied(true); const localeCurrency = effectiveCurrency !== plan.currency ? effectiveCurrency : null;
const result = await validatePromo(plan.plan_id, promoCode.trim().toUpperCase(), localeCurrency);
if (result?.valid) {
setPromoResult(result);
toast.success("Promo code applied."); toast.success("Promo code applied.");
return; } else {
toast.error(result?.reason ?? "Invalid promo code.");
} }
toast.error("Invalid promo code."); };
const handleRemovePromo = () => {
setPromoResult(null);
setPromoCode("");
}; };
const handlePayPal = async () => { const handlePayPal = async () => {
const localeCurrency = effectiveCurrency !== plan.currency ? effectiveCurrency : null;
const order = await createOrder( const order = await createOrder(
plan.plan_id, plan.plan_id,
isPromoApplied ? promoCode.trim().toUpperCase() : null promoResult?.code ?? null,
localeCurrency,
); );
if (!order) return; if (!order) return;
const approvalUrl = order.approval_url; const approvalUrl = order.approval_url;
@@ -198,6 +237,31 @@ const Checkout = () => {
); );
} }
if (!plan.is_active) {
return (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-3xl mx-auto px-6 pb-6 space-y-4">
<AppBreadcrumb items={breadcrumbItems} />
<Card>
<CardContent className="py-10 text-center space-y-4">
<LockIcon className="size-10 mx-auto text-muted-foreground/50" />
<div>
<h1 className="text-xl font-semibold">Plan Not Available</h1>
<p className="text-sm text-muted-foreground mt-1">
The <span className="font-medium text-foreground">{plan.label}</span> plan
is not available for purchase at the moment.
</p>
</div>
<Button onClick={() => navigate("/plans")}>
<ArrowLeft className="size-4" /> Back to Plans
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen bg-muted pt-24"> <div className="min-h-screen bg-muted pt-24">
<PageMeta title={plan ? `Checkout – ${plan.label} - STARR` : undefined} /> <PageMeta title={plan ? `Checkout – ${plan.label} - STARR` : undefined} />
@@ -233,9 +297,25 @@ const Checkout = () => {
{(plan.course_count ?? plan.courses?.length ?? 0) === 1 ? "" : "s"} included {(plan.course_count ?? plan.courses?.length ?? 0) === 1 ? "" : "s"} included
</p> </p>
</div> </div>
<p className="text-2xl font-bold text-primary"> <div className="flex items-center gap-3 flex-wrap">
{fmtCurrency(plan.price, plan.currency)} <p className="text-2xl font-bold text-primary">
</p> {fmtPlanPrice(plan)}
</p>
{plan.prices?.length > 0 && (
<Select value={currency} onValueChange={handleCurrencyChange}>
<SelectTrigger className="h-8 w-28 text-xs">
<Globe className="size-3 mr-1 shrink-0" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={plan.currency}>{plan.currency}</SelectItem>
{plan.prices.map((p) => (
<SelectItem key={p.currency} value={p.currency}>{p.currency}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div> </div>
</div> </div>
@@ -309,12 +389,12 @@ const Checkout = () => {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between gap-4"> <div className="flex justify-between gap-4">
<span className="text-muted-foreground">Plan Price</span> <span className="text-muted-foreground">Plan Price</span>
<span className="font-medium">{fmtCurrency(subtotal, plan.currency)}</span> <span className="font-medium">{fmtCurrency(subtotal, effectiveCurrency)}</span>
</div> </div>
{isPromoApplied && ( {promoResult?.valid && (
<div className="flex justify-between gap-4 text-green-600"> <div className="flex justify-between gap-4 text-green-600">
<span>Promo Discount (PHIL10)</span> <span>Promo ({promoResult.code})</span>
<span>-{fmtCurrency(discount, plan.currency)}</span> <span>-{fmtCurrency(promoResult.discount, effectiveCurrency)}</span>
</div> </div>
)} )}
</div> </div>
@@ -323,29 +403,43 @@ const Checkout = () => {
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="promo">Promo Code</Label> <Label htmlFor="promo">Promo Code</Label>
<div className="flex gap-2"> {promoResult?.valid ? (
<Input <div className="flex items-center gap-2 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
id="promo" <Check className="size-4 shrink-0" />
placeholder="PHIL10" <span className="flex-1 font-medium">{promoResult.code}</span>
value={promoCode} <button
onChange={(e) => setPromoCode(e.target.value)} onClick={handleRemovePromo}
disabled={isPromoApplied || checkoutLoading} className="text-green-500 hover:text-green-700 text-xs underline"
/> >
<Button Remove
variant="outline" </button>
onClick={handleApplyPromo} </div>
disabled={isPromoApplied || checkoutLoading || !promoCode.trim()} ) : (
> <div className="flex gap-2">
Apply <Input
</Button> id="promo"
</div> placeholder="Enter promo code"
value={promoCode}
onChange={(e) => setPromoCode(e.target.value)}
disabled={promoLoading || checkoutLoading}
onKeyDown={(e) => e.key === "Enter" && promoCode.trim() && handleApplyPromo()}
/>
<Button
variant="outline"
onClick={handleApplyPromo}
disabled={promoLoading || checkoutLoading || !promoCode.trim()}
>
{promoLoading ? <Loader2 className="size-4 animate-spin" /> : "Apply"}
</Button>
</div>
)}
</div> </div>
<Separator /> <Separator />
<div className="flex justify-between items-center text-lg font-semibold"> <div className="flex justify-between items-center text-lg font-semibold">
<span>Total</span> <span>Total</span>
<span>{fmtCurrency(total, plan.currency)}</span> <span>{fmtCurrency(total, effectiveCurrency)}</span>
</div> </div>
{isCurrent ? ( {isCurrent ? (
@@ -363,10 +457,9 @@ const Checkout = () => {
? <Loader2 className="size-4 animate-spin" /> ? <Loader2 className="size-4 animate-spin" />
: <ShieldCheck className="size-4" /> : <ShieldCheck className="size-4" />
} }
Pay {fmtCurrency(total, plan.currency)} with PayPal Pay {fmtCurrency(total, effectiveCurrency)} with PayPal
</Button> </Button>
)} )}
<div className="text-center text-sm text-muted-foreground space-y-1"> <div className="text-center text-sm text-muted-foreground space-y-1">
<p className="inline-flex items-center justify-center gap-1"> <p className="inline-flex items-center justify-center gap-1">
<ShieldCheck className="size-4" /> <ShieldCheck className="size-4" />
+203 -61
View File
@@ -4,8 +4,9 @@ import { useParams, useNavigate } from "react-router-dom";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { import {
House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon, House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon,
SendHorizonal, CheckCheck, CheckCircle2, Clock, SendHorizonal, CheckCheck, CheckCircle2, Clock, FileQuestion, ClipboardList,
} from "lucide-react"; } from "lucide-react";
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -35,22 +36,6 @@ function formatDuration(seconds = 0) {
return `${m}min`; return `${m}min`;
} }
// ─── Certificate badge icon ────────────────────────────────────────────────────
const CertBadgeIcon = ({ className }) => (
<svg className={className} width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Prism logo">
<defs>
<linearGradient id="prism-cd" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#8EA2F6"/>
<stop offset="1" stopColor="#5061E6"/>
</linearGradient>
</defs>
<g transform="rotate(45 60 60)">
<rect x="24" y="24" width="72" height="72" rx="16" fill="url(#prism-cd)"/>
<rect x="46" y="46" width="28" height="28" rx="6" fill="#FFFFFF"/>
</g>
</svg>
);
// ─── Spine / card helpers ────────────────────────────────────────────────────── // ─── Spine / card helpers ──────────────────────────────────────────────────────
@@ -88,6 +73,7 @@ const useVisibleNodes = (refs, count) => {
const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle, isCompleted }) => { const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle, isCompleted }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const quiz = unit.quiz ?? null;
return ( return (
<motion.div <motion.div
@@ -125,12 +111,17 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle,
<Timer /> {formatDuration(unit.duration_seconds)} <Timer /> {formatDuration(unit.duration_seconds)}
</div> </div>
)} )}
{quiz && (
<div className="flex items-center gap-1.5">
<FileQuestion /> Quiz
</div>
)}
</div> </div>
</div> </div>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="px-1.5 h-full"> <AccordionContent className="px-1.5 h-full">
<div className="flex flex-col gap-1 pt-0"> <div className="flex flex-col gap-1 pt-0">
{(unit.lessons ?? []).map((lesson, li) => ( {(unit.lessons ?? []).map((lesson) => (
<div <div
key={lesson.lesson_id} key={lesson.lesson_id}
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-200 dark:hover:bg-blue-500 transition-colors cursor-pointer" className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-200 dark:hover:bg-blue-500 transition-colors cursor-pointer"
@@ -148,6 +139,28 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle,
)} )}
</div> </div>
))} ))}
{/* Quiz row — shown after lessons if unit has a quiz */}
{quiz && (
<div
className="flex items-center justify-between py-2 px-3 mt-1 rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/60 dark:bg-blue-950/30 hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors cursor-pointer"
onClick={() => navigate(`/course/${courseId}/unit`, { state: { quizUnitId: unit.unit_id } })}
>
<div className="flex items-center gap-3 select-none">
{quiz.has_passed
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
: <FileQuestion className="size-4 text-blue-500 shrink-0" />
}
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">{quiz.title}</span>
</div>
<Badge className={quiz.has_passed
? "bg-green-100 text-green-700 border border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 text-[10px]"
: "bg-blue-100 text-blue-700 border border-blue-300 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700 text-[10px]"
}>
{quiz.has_passed ? "Passed" : "Quiz"}
</Badge>
</div>
)}
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
@@ -156,15 +169,73 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle,
); );
}; };
// ─── Assessment Card ──────────────────────────────────────────────────────────
const AssessmentCard = ({ assessment, courseId, delay, nodeRef }) => {
const navigate = useNavigate();
const passed = assessment.has_passed;
return (
<motion.div
ref={nodeRef}
className="w-full rounded-2xl border bg-card p-5 flex flex-col gap-3 shadow-sm"
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay, ease: "easeOut" }}
>
<div className="flex items-center w-full justify-between">
<div className="flex items-center gap-2.5">
<div className="h-9 w-9 rounded-lg bg-violet-100 dark:bg-violet-900/40 flex items-center justify-center shrink-0">
<ClipboardList className="h-4.5 w-4.5 text-violet-600 dark:text-violet-400" />
</div>
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">Final Assessment</p>
<p className="text-sm font-semibold leading-tight truncate">{assessment.title}</p>
</div>
</div>
{(() => {
const count = assessment.max_questions ?? assessment.question_count;
if (!count) return null;
return (
<div className="text-sm text-muted-foreground shrink-0">
{count} {count === 1 ? "question" : "questions"}
</div>
);
})()}
</div>
<div className="flex items-center justify-between">
{passed ? (
<Badge className="bg-green-100 text-green-700 border border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1">
<CheckCircle2 className="size-3" /> Passed
</Badge>
) : (
<Badge className="bg-muted text-muted-foreground border gap-1">
<Clock className="size-3" /> Not yet passed
</Badge>
)}
<Button
type="button"
size="sm"
variant={passed ? "outline" : "default"}
className="h-7 text-xs"
onClick={() => navigate(`/course/${courseId}/unit`, { state: { seekAssessment: true } })}
>
{passed ? "Review" : "Take Assessment"}
</Button>
</div>
</motion.div>
);
};
// ─── Certificate Card ───────────────────────────────────────────────────────── // ─── Certificate Card ─────────────────────────────────────────────────────────
const CertCard = ({ courseTitle, courseLevel, pendingCert, certificate, delay, nodeRef }) => { const CertCard = ({ courseTitle, courseLevel, badgeColor, badgeImageUrl, pendingCert, certificate, delay, nodeRef }) => {
const { fmtDate } = useDateFormat(); const { fmtDate } = useDateFormat();
const isIssued = !!certificate; const isIssued = !!certificate;
const isPending = !isIssued && !!pendingCert; const isPending = !isIssued && !!pendingCert;
let issuedLabel = "Upon completion"; let issuedLabel = "Upon completion";
if (isIssued) issuedLabel = fmtDate(certificate.issued_at); if (isIssued) issuedLabel = fmtDate(certificate.issued_at);
if (isPending) issuedLabel = fmtDate(pendingCert.issue_at); if (isPending) issuedLabel = fmtDate(pendingCert.issue_at);
return ( return (
@@ -176,30 +247,49 @@ const CertCard = ({ courseTitle, courseLevel, pendingCert, certificate, delay, n
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay, ease: "easeOut" }} transition={{ duration: 0.35, delay, ease: "easeOut" }}
> >
<CertBadgeIcon className="w-24" /> <CourseBadge
<p className="text-lg font-bold text-center leading-snug capitalize">{`${courseLevel} Level`}</p> title={courseTitle}
<div className="w-full rounded-lg border px-3 py-2.5"> level={courseLevel}
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">Course</p> color={badgeColor ?? "purple"}
<p className="text-sm font-medium mt-1">{courseTitle}</p> imageUrl={badgeImageUrl}
</div> />
<div className="w-full flex items-end justify-between"> <div className="w-full flex items-end justify-between">
<div> <div>
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">Issued</p> <p className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">
{isIssued ? "Issued" : "Available"}
</p>
<p className="text-sm text-foreground mt-0.5">{issuedLabel}</p> <p className="text-sm text-foreground mt-0.5">{issuedLabel}</p>
</div> </div>
<DialogTrigger asChild> {isIssued ? (
<Badge className="bg-blue-100 text-blue-700 border border-blue-400 dark:bg-blue-900/40 dark:text-blue-400 dark:border-blue-700 gap-1 cursor-pointer"> <DialogTrigger asChild>
<Clock className="size-3" /> Issued <Badge className="bg-green-100 text-green-700 border border-green-400 dark:bg-green-900/40 dark:text-green-400 dark:border-green-700 gap-1 cursor-pointer">
</Badge> <CheckCircle2 className="size-3" /> Issued
</DialogTrigger> </Badge>
</DialogTrigger>
) : isPending ? (
<DialogTrigger asChild>
<Badge className="bg-amber-100 text-amber-700 border border-amber-400 dark:bg-amber-900/40 dark:text-amber-400 dark:border-amber-700 gap-1 cursor-pointer">
<Clock className="size-3" /> Pending
</Badge>
</DialogTrigger>
) : (
<DialogTrigger asChild>
<Badge className="bg-muted text-muted-foreground border gap-1 cursor-pointer">
<Clock className="size-3" /> Not yet earned
</Badge>
</DialogTrigger>
)}
</div> </div>
</motion.div> </motion.div>
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Clock className="size-4 text-blue-500" /> {isIssued
? <CheckCircle2 className="size-4 text-green-500" />
: <Clock className="size-4 text-muted-foreground" />}
{isIssued ? "Certificate Issued" : isPending ? "Certificate Pending" : "Certificate"} {isIssued ? "Certificate Issued" : isPending ? "Certificate Pending" : "Certificate"}
</DialogTitle> </DialogTitle>
<DialogDescription asChild> <DialogDescription asChild>
@@ -254,11 +344,18 @@ const CertCard = ({ courseTitle, courseLevel, pendingCert, certificate, delay, n
// ─── Course Units (spine + cards) ───────────────────────────────────────────── // ─── Course Units (spine + cards) ─────────────────────────────────────────────
const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, onToggle, isCompleted, pendingCert, certificate }) => { const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, badgeColor, badgeImageUrl, onToggle, isCompleted, pendingCert, certificate, assessment }) => {
const wrapRef = useRef(null); const wrapRef = useRef(null);
const cardRefs = useRef([]); const cardRefs = useRef([]);
// +1 for the certificate card at the end
const totalNodes = units.length + 1; // Build a flat ordered list of nodes: [unit, unit, ..., assessment?, cert]
// Quiz is rendered inside each unit accordion, not as a separate spine node.
const nodes = [
...units.map((unit) => ({ type: "unit", unit })),
...(assessment ? [{ type: "assessment", assessment }] : []),
{ type: "cert" },
];
const totalNodes = nodes.length;
const visibleNodes = useVisibleNodes(cardRefs, totalNodes); const visibleNodes = useVisibleNodes(cardRefs, totalNodes);
const [mids, setMids] = useState([]); const [mids, setMids] = useState([]);
const maxVisible = visibleNodes.size ? Math.max(...visibleNodes) : -1; const maxVisible = visibleNodes.size ? Math.max(...visibleNodes) : -1;
@@ -278,7 +375,7 @@ const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, onToggle,
const id = setTimeout(measure, 60); const id = setTimeout(measure, 60);
window.addEventListener("resize", measure); window.addEventListener("resize", measure);
return () => { clearTimeout(id); window.removeEventListener("resize", measure); }; return () => { clearTimeout(id); window.removeEventListener("resize", measure); };
}, [units.length]); }, [totalNodes]);
const lastMid = mids.length ? mids[mids.length - 1] : 0; const lastMid = mids.length ? mids[mids.length - 1] : 0;
const svgH = lastMid + 40; const svgH = lastMid + 40;
@@ -343,28 +440,53 @@ const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, onToggle,
{/* Cards */} {/* 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-10 lg:-ml-0 flex flex-col gap-6 flex-1 max-w-3xl" style={{ paddingTop: INTRO_HEIGHT }}>
{units.map((unit, i) => ( {nodes.map((node, ni) => {
<UnitAccordionBlock const delay = ni * 0.05;
key={unit.unit_id} const nodeRef = (el) => (cardRefs.current[ni] = el);
unit={unit}
unitIndex={i}
i={i}
cardRefs={cardRefs}
courseId={courseId}
onToggle={measure}
isCompleted={isCompleted}
/>
))}
{/* Certificate badge — final node, always present */} if (node.type === "unit") {
<CertCard const unitIndex = units.indexOf(node.unit);
nodeRef={(el) => (cardRefs.current[units.length] = el)} return (
delay={units.length * 0.05} <UnitAccordionBlock
courseTitle={courseTitle} key={node.unit.unit_id}
courseLevel={courseLevel} unit={node.unit}
pendingCert={pendingCert} unitIndex={unitIndex}
certificate={certificate} i={ni}
/> cardRefs={cardRefs}
courseId={courseId}
onToggle={measure}
isCompleted={isCompleted}
/>
);
}
if (node.type === "assessment") {
return (
<AssessmentCard
key="assessment"
assessment={node.assessment}
courseId={courseId}
delay={delay}
nodeRef={nodeRef}
/>
);
}
// cert
return (
<CertCard
key="cert"
nodeRef={nodeRef}
delay={delay}
courseTitle={courseTitle}
courseLevel={courseLevel}
badgeColor={badgeColor}
badgeImageUrl={badgeImageUrl}
pendingCert={pendingCert}
certificate={certificate}
/>
);
})}
</div> </div>
</div> </div>
); );
@@ -383,6 +505,7 @@ const CourseDetails = () => {
const { fetchCourseProgress, isCompleted, resetProgress } = useCourseReadingProgress(); const { fetchCourseProgress, isCompleted, resetProgress } = useCourseReadingProgress();
const [tierMap, setTierMap] = useState({}); const [tierMap, setTierMap] = useState({});
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
useEffect(() => { useEffect(() => {
api.get("/client/tiers/categories") api.get("/client/tiers/categories")
.then(({ data }) => { .then(({ data }) => {
@@ -390,7 +513,7 @@ const CourseDetails = () => {
(data.data ?? []).forEach((c) => { m[c.slug] = c; }); (data.data ?? []).forEach((c) => { m[c.slug] = c; });
setTierMap(m); setTierMap(m);
}) })
.catch(() => {}); .catch(() => { });
}, []); }, []);
const hasCompleted = !!course?.is_completed; const hasCompleted = !!course?.is_completed;
@@ -403,9 +526,25 @@ const CourseDetails = () => {
getMyTier(); getMyTier();
getCourse(courseId); getCourse(courseId);
fetchCourseProgress(courseId); fetchCourseProgress(courseId);
return () => { resetCourse(); resetProgress(); }; return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); };
}, [courseId]); }, [courseId]);
// Resolve badge image once course loads — issue a client stream token for
// private S3 assets so the badge preview works on this page.
useEffect(() => {
if (!course?.badge_asset_id) {
setBadgeImageUrl(course?.badge_image_url ?? null);
return;
}
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
api.post("/client/media/token", { asset_id: course.badge_asset_id })
.then(({ data }) => {
const token = data.data?.token;
setBadgeImageUrl(token ? `${STREAM_BASE}/${token}` : null);
})
.catch(() => setBadgeImageUrl(course.badge_image_url ?? null));
}, [course?.badge_asset_id, course?.badge_image_url]);
if (courseBlocked) { if (courseBlocked) {
toast.error("You don't have access to this course. Upgrade your plan."); toast.error("You don't have access to this course. Upgrade your plan.");
navigate("/course", { replace: true }); navigate("/course", { replace: true });
@@ -535,9 +674,12 @@ const CourseDetails = () => {
courseId={courseId} courseId={courseId}
courseTitle={course.title} courseTitle={course.title}
courseLevel={course.level} courseLevel={course.level}
badgeColor={course.badge_color ?? "purple"}
badgeImageUrl={badgeImageUrl}
isCompleted={isCompleted} isCompleted={isCompleted}
pendingCert={course.pending_certificate ?? null} pendingCert={course.pending_certificate ?? null}
certificate={course.certificate ?? null} certificate={course.certificate ?? null}
assessment={course.assessment ?? null}
/> />
</> </>
)} )}
+9 -12
View File
@@ -17,6 +17,7 @@ import { PageMeta } from "@/contexts/MetadataContext";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { resolveTierBadge } from "@/utils/tierBadge.util"; import { resolveTierBadge } from "@/utils/tierBadge.util";
import { Building2 } from "lucide-react";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -169,11 +170,16 @@ const CoursesList = () => {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [selectedCourse, setSelectedCourse] = useState(null); const [selectedCourse, setSelectedCourse] = useState(null);
const [allCategories, setAllCategories] = useState([]);
useEffect(() => { useEffect(() => {
getCourses(); getCourses();
api.get("/client/tiers/categories") api.get("/client/tiers/categories")
.then(({ data }) => setTierCategories(data.data ?? [])) .then(({ data }) => setTierCategories(data.data ?? []))
.catch(() => {}); .catch(() => {});
api.get("/client/courses/categories")
.then(({ data }) => setAllCategories(data.data ?? []))
.catch(() => {});
}, []); }, []);
// slug → category info map // slug → category info map
@@ -183,13 +189,6 @@ const CoursesList = () => {
return m; return m;
}, [tierCategories]); }, [tierCategories]);
// Collect unique product categories from loaded courses
const allCategories = useMemo(() => {
const map = new Map();
courses.forEach((c) => (c.categories ?? []).forEach((cat) => map.set(cat.id, cat)));
return [...map.values()];
}, [courses]);
const filtered = useMemo(() => const filtered = useMemo(() =>
courses courses
.filter((c) => { .filter((c) => {
@@ -228,7 +227,7 @@ const CoursesList = () => {
<div> <div>
<PageMeta title="Courses - STARR" description="Browse your available training courses." /> <PageMeta title="Courses - STARR" description="Browse your available training courses." />
<div className="py-24 bg-accent/70 min-h-screen"> <div className="py-24 bg-accent/70 min-h-screen">
<div className="flex flex-col gap-8 justify-between lg:container lg:mx-auto pt-2"> <div className="flex flex-col gap-4 justify-between lg:container lg:mx-auto pt-2">
<AppBreadcrumb items={items} /> <AppBreadcrumb items={items} />
{/* Search & Filters */} {/* Search & Filters */}
@@ -298,10 +297,8 @@ const CoursesList = () => {
</div> </div>
) : paginated.length === 0 ? ( ) : paginated.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20"> <div className="flex flex-col items-center justify-center py-20">
<svg className="w-10 h-10 mb-3" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24"> <Building2 className="size-40 text-primary" />
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <p className="text-md">No courses found</p>
</svg>
<p className="text-sm">No courses found</p>
</div> </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">
+64 -26
View File
@@ -8,23 +8,9 @@ import { useProfile } from "@/contexts/ProfileProvider";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { toast } from "sonner"; import { toast } from "sonner";
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
const CertBadgeIcon = ({ className }) => ( const CertificateCard = ({ courseTitle, issuedAt, courseUuid, badgeColor, badgeImageUrl }) => {
<svg className={className} width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Prism logo">
<defs>
<linearGradient id="prism-mc" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#8EA2F6"/>
<stop offset="1" stopColor="#5061E6"/>
</linearGradient>
</defs>
<g transform="rotate(45 60 60)">
<rect x="24" y="24" width="72" height="72" rx="16" fill="url(#prism-mc)"/>
<rect x="46" y="46" width="28" height="28" rx="6" fill="#FFFFFF"/>
</g>
</svg>
);
const CertificateCard = ({ courseTitle, issuedAt, courseUuid }) => {
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const { fmtDate } = useDateFormat(); const { fmtDate } = useDateFormat();
@@ -52,8 +38,11 @@ const CertificateCard = ({ courseTitle, issuedAt, courseUuid }) => {
return ( return (
<div className="rounded-2xl border bg-card p-6 flex flex-col items-center gap-4 shadow-sm"> <div className="rounded-2xl border bg-card p-6 flex flex-col items-center gap-4 shadow-sm">
<CertBadgeIcon className="w-[160px] h-[160px]" /> <CourseBadge
<p className="text-lg font-bold text-center leading-snug">Certificate of Completion</p> title={courseTitle}
color={badgeColor ?? "purple"}
imageUrl={badgeImageUrl ?? null}
/>
<div className="w-full rounded-lg border px-3 py-2.5"> <div className="w-full rounded-lg border px-3 py-2.5">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">Course</p> <p className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">Course</p>
<p className="text-sm font-medium mt-1 line-clamp-2">{courseTitle}</p> <p className="text-sm font-medium mt-1 line-clamp-2">{courseTitle}</p>
@@ -79,10 +68,53 @@ export default function MyCertificates() {
const navigate = useNavigate(); const navigate = useNavigate();
const { achievements, achievementsLoading, getAchievements } = useProfile(); const { achievements, achievementsLoading, getAchievements } = useProfile();
// { [courseUuid]: { badge_color, badge_image_url, badge_asset_id } }
const [badgeDataMap, setBadgeDataMap] = useState({});
useEffect(() => { getAchievements(); }, []); useEffect(() => { getAchievements(); }, []);
const certAchievements = achievements.filter((a) => a.key.startsWith("course_completed_")); const certAchievements = achievements.filter((a) => a.key.startsWith("course_completed_"));
// Fetch badge data for all certificates once the list is known
useEffect(() => {
if (!certAchievements.length) return;
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
certAchievements.forEach((cert) => {
const uuid = cert.metadata?.courseUuid ?? cert.key.replace("course_completed_", "");
if (!uuid || badgeDataMap[uuid]) return;
api.get(`/client/courses/uuid/${uuid}`)
.then(({ data }) => {
const c = data.data ?? data;
if (!c) return;
if (c.badge_asset_id) {
api.post("/client/media/token", { asset_id: c.badge_asset_id })
.then(({ data: td }) => {
const token = td.data?.token;
setBadgeDataMap((prev) => ({
...prev,
[uuid]: {
badge_color: c.badge_color ?? "purple",
badge_image_url: token ? `${STREAM_BASE}/${token}` : null,
},
}));
})
.catch(() => setBadgeDataMap((prev) => ({
...prev,
[uuid]: { badge_color: c.badge_color ?? "purple", badge_image_url: null },
})));
} else {
setBadgeDataMap((prev) => ({
...prev,
[uuid]: { badge_color: c.badge_color ?? "purple", badge_image_url: c.badge_image_url ?? null },
}));
}
})
.catch(() => {});
});
}, [certAchievements.length]);
return ( return (
<section className="mt-17 bg-muted min-h-full"> <section className="mt-17 bg-muted min-h-full">
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5"> <div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5">
@@ -116,14 +148,20 @@ export default function MyCertificates() {
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{certAchievements.map((cert) => ( {certAchievements.map((cert) => {
<CertificateCard const uuid = cert.metadata?.courseUuid ?? cert.key.replace("course_completed_", "");
key={cert.achievement_id} const bData = badgeDataMap[uuid] ?? {};
courseTitle={cert.description} return (
issuedAt={cert.granted_at} <CertificateCard
courseUuid={cert.metadata?.courseUuid ?? cert.key.replace("course_completed_", "")} key={cert.achievement_id}
/> courseTitle={cert.description}
))} issuedAt={cert.granted_at}
courseUuid={uuid}
badgeColor={bData.badge_color ?? "purple"}
badgeImageUrl={bData.badge_image_url ?? null}
/>
);
})}
</div> </div>
)} )}
+248 -107
View File
@@ -8,6 +8,10 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { import {
Megaphone, BookOpen, Clock, Check, Megaphone, BookOpen, Clock, Check,
Tag, LockIcon, Zap, RotateCcw, Tag, LockIcon, Zap, RotateCcw,
@@ -18,6 +22,7 @@ import { toast } from "sonner";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import { useCurrency } from "@/hooks/useCurrency";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -29,11 +34,13 @@ function formatCountdown(secs) {
return `${m}:${String(s).padStart(2, "0")}`; return `${m}:${String(s).padStart(2, "0")}`;
} }
function formatDuration(days) { function formatDuration(days, unit) {
if (!days) return null; if (!days) return null;
if (days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""}`; const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
if (days % 30 === 0) return `${days / 30} month${days / 30 > 1 ? "s" : ""}`; const multiplier = UNIT_TO_DAYS[unit] ?? 1;
return `${days} days`; const value = Math.round((days / multiplier) * 1000) / 1000;
const label = unit ?? "day";
return `${value} ${label}${value !== 1 ? "s" : ""}`;
} }
function formatCourseDuration(seconds = 0) { function formatCourseDuration(seconds = 0) {
@@ -48,25 +55,25 @@ function formatCourseDuration(seconds = 0) {
// Badge styles per tier // Badge styles per tier
const TIER_STYLES = { const TIER_STYLES = {
free: { free: {
badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white", badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white",
button: "default", button: "default",
icon: Tag, icon: Tag,
label: "Free", label: "Free",
ring: "", ring: "",
}, },
premium: { premium: {
badge: "bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white", badge: "bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white",
button: "default", button: "default",
icon: Zap, icon: Zap,
label: "Premium", label: "Premium",
ring: "ring-2 ring-fuchsia-400/40", ring: "ring-2 ring-fuchsia-400/40",
}, },
exclusive: { exclusive: {
badge: "bg-gradient-to-r from-rose-500 to-red-600 text-white", badge: "bg-gradient-to-r from-rose-500 to-red-600 text-white",
button: "default", button: "default",
icon: LockIcon, icon: LockIcon,
label: "Exclusive", label: "Exclusive",
ring: "ring-2 ring-rose-400/40", ring: "ring-2 ring-rose-400/40",
}, },
}; };
@@ -94,104 +101,237 @@ const PlanSkeleton = () => (
// ─── Plan Card ──────────────────────────────────────────────────────────────── // ─── Plan Card ────────────────────────────────────────────────────────────────
const PREVIEW_COURSE_LIMIT = 2;
const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft }) => { const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft }) => {
const { fmtCurrency } = useDateFormat(); const { fmtPlanPrice } = useCurrency();
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free; const [coursesOpen, setCoursesOpen] = useState(false);
const Icon = style.icon; const [notAvailableOpen, setNotAvailableOpen] = useState(false);
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
const Icon = style.icon;
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active"; const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
const duration = formatDuration(plan.duration_days); const duration = formatDuration(plan.duration_days, plan.duration_unit);
const previewCourses = plan.courses?.slice(0, PREVIEW_COURSE_LIMIT) ?? [];
const extraCount = (plan.courses?.length ?? 0) - PREVIEW_COURSE_LIMIT;
return ( return (
<Card className={`relative flex flex-col ${style.ring}`}> <>
<CardHeader> <Card className={`relative flex flex-col ${style.ring}`}>
<div className="flex items-center justify-between"> <CardHeader>
<CardTitle>{plan.label}</CardTitle> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <CardTitle>{plan.label}</CardTitle>
{isCurrent && ( <div className="flex items-center gap-2">
<Badge className="bg-green-500 text-white">Current Plan</Badge> {isCurrent && (
<Badge className="bg-green-500 text-white">Current Plan</Badge>
)}
<Badge className={style.badge}>
<Icon />
{style.label}
</Badge>
</div>
</div>
<CardDescription>
<span className="text-3xl font-bold text-foreground">
{fmtPlanPrice(plan)}
</span>
{duration && (
<span className="text-sm text-muted-foreground ml-1">/ {duration}</span>
)} )}
<Badge className={style.badge}> </CardDescription>
<Icon /> </CardHeader>
{style.label}
</Badge>
</div>
</div>
<CardDescription>
<span className="text-3xl font-bold text-foreground">
{fmtCurrency(plan.price, plan.currency)}
</span>
{duration && (
<span className="text-sm text-muted-foreground ml-1">/ {duration}</span>
)}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 space-y-4"> <CardContent className="flex-1 space-y-4">
{plan.courses?.length > 0 ? (
<div className="space-y-2"> {plan.courses?.length > 0 ? (
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5"> <div className="space-y-2">
<BookOpen className="size-3.5" /> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
Course{plan.course_count !== 1 ? "s" : ""} Included <BookOpen className="size-3.5" />
</p> Course{plan.course_count !== 1 ? "s" : ""} Included
<ul className="space-y-1.5"> </p>
{plan.courses.map((course) => ( <ul className="space-y-1.5">
<li key={course.course_id} className="flex items-start gap-2 text-sm"> {previewCourses.map((course) => (
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" /> <li key={course.course_id} className="flex items-start gap-2 text-sm">
<div className="flex-1 min-w-0"> <Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
<span className="line-clamp-1">{course.title}</span> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mt-0.5"> <span className="line-clamp-1">{course.title}</span>
{course.level && ( <div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground capitalize">{course.level}</span> {course.level && (
)} <span className="text-xs text-muted-foreground capitalize">{course.level}</span>
{formatCourseDuration(course.duration_seconds) && ( )}
<span className="text-xs text-muted-foreground flex items-center gap-1"> {formatCourseDuration(course.duration_seconds) && (
<Clock className="size-3" /> <span className="text-xs text-muted-foreground flex items-center gap-1">
{formatCourseDuration(course.duration_seconds)} <Clock className="size-3" />
</span> {formatCourseDuration(course.duration_seconds)}
)} </span>
)}
</div>
</div> </div>
</div> </li>
</li> ))}
))} </ul>
</ul> {extraCount > 0 && (
</div> <Badge
) : ( type="button"
<p className="text-sm text-muted-foreground"> // onClick={() => setCoursesOpen(true)}
Access to all free course content. // DIALOG DISABLED
variant="secondary"
>
+{extraCount} more course{extraCount !== 1 ? "s" : ""}
</Badge>
)}
</div>
) : !plan.description ? (
<p className="text-sm text-muted-foreground">
Access to all free course content.
</p>
) : null}
</CardContent>
<Separator />
<CardFooter className="flex gap-2 pt-4">
<Button
className="flex-1"
variant="outline"
onClick={() => onView(plan)}
>
View Details
</Button>
{isCurrent && refundSecsLeft > 0 ? (
<Button
className="flex-1"
variant="destructive"
onClick={() => onRefund(plan)}
>
<RotateCcw className="size-4" />
Refund ({formatCountdown(refundSecsLeft)})
</Button>
) : !plan.is_active ? (
<Button
className="flex-1"
variant="secondary"
onClick={() => setNotAvailableOpen(true)}
>
Not Available
</Button>
) : !isCurrent ? (
<Button
className="flex-1"
variant={style.button}
onClick={() => onSelect(plan)}
>
{plan.tier === "free" ? "Current" : `Get ${style.label}`}
</Button>
) : null}
</CardFooter>
</Card>
{/* ── Not Available Dialog ──────────────────────────────────────── */}
<Dialog open={notAvailableOpen} onOpenChange={setNotAvailableOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Unavailable</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground py-1">
The <span className="font-medium text-foreground">{plan.label}</span> plan
is currently not available for purchase. Please check back later.
</p> </p>
)} <DialogFooter>
</CardContent> <Button variant="outline" onClick={() => setNotAvailableOpen(false)}>
Got it
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Separator /> {/* ── Plan Detail Dialog ─────────────────────────────────────────── */}
{/* Current disabled from line 170 to 172 */}
<Dialog open={coursesOpen} onOpenChange={setCoursesOpen}>
<DialogContent className="sm:max-w-[calc(100%-55rem)]">
<DialogHeader>
<div className="flex items-start gap-2">
<DialogTitle className="leading-snug">{plan.label}</DialogTitle>
<Badge className={`${style.badge} shrink-0`}>
<Icon className="size-3" />
{style.label}
</Badge>
</div>
</DialogHeader>
<CardFooter className="flex gap-2 pt-4"> {/* Price + Duration */}
<Button <div className="flex items-baseline gap-1.5">
className="flex-1" <span className="text-2xl font-bold">
variant="outline" {fmtPlanPrice(plan)}
onClick={() => onView(plan)} </span>
> {duration && (
View Details <span className="text-sm text-muted-foreground">/ {duration}</span>
</Button> )}
{isCurrent && refundSecsLeft > 0 ? ( </div>
<Button
className="flex-1" {/* Description */}
variant="destructive" {plan.description && (
onClick={() => onRefund(plan)} <p className="text-sm text-muted-foreground leading-relaxed -mt-1">
> {plan.description}
<RotateCcw className="size-4" /> </p>
Refund ({formatCountdown(refundSecsLeft)}) )}
</Button>
) : !isCurrent ? ( <Separator />
<Button
className="flex-1" {/* Courses horizontal scroll */}
variant={style.button} {plan.courses?.length > 0 && (
onClick={() => onSelect(plan)} <div className="space-y-2">
> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
{plan.tier === "free" ? "Current" : `Get ${style.label}`} <BookOpen className="size-3.5" />
</Button> {plan.courses.length} Course{plan.courses.length !== 1 ? "s" : ""} Included
) : null} </p>
</CardFooter> <ScrollArea className="w-md whitespace-nowrap">
</Card> <div className="flex gap-3 pb-3 pt-1 w-max">
{plan.courses.map((course) => (
<div
key={course.course_id}
className="w-40 shrink-0 rounded-xl border bg-muted/50 p-3 space-y-2"
>
<div className="flex items-start gap-1.5">
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
<p className="text-xs font-medium leading-snug line-clamp-3">
{course.title}
</p>
</div>
<div className="flex flex-col gap-1">
{course.level && (
<span className="text-[11px] text-muted-foreground capitalize">
{course.level}
</span>
)}
{formatCourseDuration(course.duration_seconds) && (
<span className="text-[11px] text-muted-foreground flex items-center gap-1">
<Clock className="size-3" />
{formatCourseDuration(course.duration_seconds)}
</span>
)}
</div>
</div>
))}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)}
{!isCurrent && plan.tier !== "free" && (
<DialogFooter>
<Button
className="w-full"
onClick={() => { setCoursesOpen(false); onSelect(plan); }}
>
Get {style.label}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
</>
); );
}; };
@@ -200,9 +340,10 @@ const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft })
export default function PlanList() { export default function PlanList() {
const navigate = useNavigate(); const navigate = useNavigate();
const { plans, plansLoading, myTier, tierLoading, getPlans, getMyTier, resetMyTier } = useClientTiers(); const { plans, plansLoading, myTier, tierLoading, getPlans, getMyTier, resetMyTier } = useClientTiers();
const { fmtCurrency, fmtDate } = useDateFormat(); const { fmtDate } = useDateFormat();
const { fmtPlanPrice } = useCurrency();
const [refundPlan, setRefundPlan] = useState(null); // plan being refunded const [refundPlan, setRefundPlan] = useState(null); // plan being refunded
const [refundLoading, setRefundLoading] = useState(false); const [refundLoading, setRefundLoading] = useState(false);
const [refundSecsLeft, setRefundSecsLeft] = useState(0); const [refundSecsLeft, setRefundSecsLeft] = useState(0);
const refundTimerRef = useRef(null); const refundTimerRef = useRef(null);
@@ -345,7 +486,7 @@ export default function PlanList() {
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Refund amount</span> <span className="text-muted-foreground">Refund amount</span>
<span className="font-medium"> <span className="font-medium">
{refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"} {refundPlan ? fmtPlanPrice(refundPlan) : "—"}
</span> </span>
</div> </div>
{myTier?.expires_at && ( {myTier?.expires_at && (
+48 -18
View File
@@ -21,6 +21,7 @@ import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { toast } from "sonner"; import { toast } from "sonner";
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
// ─── Tier badge fallbacks (used when no policy is configured in DB) ─────────── // ─── Tier badge fallbacks (used when no policy is configured in DB) ───────────
@@ -113,22 +114,7 @@ const getFallbackIcon = (type) => type === "badge" ? BadgeCheck : Trophy;
// ─── Certificate landscape card (profile preview) ──────────────────────────── // ─── Certificate landscape card (profile preview) ────────────────────────────
const CertBadgeIcon = ({ className }) => ( const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid, badgeColor, badgeImageUrl }) => {
<svg className={className} width="120" height="120" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Prism logo">
<defs>
<linearGradient id="prism-p" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#8EA2F6"/>
<stop offset="1" stopColor="#5061E6"/>
</linearGradient>
</defs>
<g transform="rotate(45 60 60)">
<rect x="24" y="24" width="72" height="72" rx="16" fill="url(#prism-p)"/>
<rect x="46" y="46" width="28" height="28" rx="6" fill="#FFFFFF"/>
</g>
</svg>
);
const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid }) => {
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const { fmtDate } = useDateFormat(); const { fmtDate } = useDateFormat();
@@ -156,7 +142,7 @@ const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid }) => {
return ( return (
<div className="rounded-xl border bg-muted/40 p-3.5 flex items-center gap-3.5"> <div className="rounded-xl border bg-muted/40 p-3.5 flex items-center gap-3.5">
<CertBadgeIcon className="w-12 h-12 shrink-0" /> <CourseBadge mini color={badgeColor ?? "purple"} imageUrl={badgeImageUrl ?? null} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">Certificate of Completion</p> <p className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">Certificate of Completion</p>
<p className="text-sm font-semibold truncate mt-0.5">{courseTitle}</p> <p className="text-sm font-semibold truncate mt-0.5">{courseTitle}</p>
@@ -195,6 +181,20 @@ const ProfilePage = () => {
const [badgeOpen, setBadgeOpen] = useState(false); const [badgeOpen, setBadgeOpen] = useState(false);
const [selectedBadge, setSelectedBadge] = useState(null); const [selectedBadge, setSelectedBadge] = useState(null);
const [badgeSrc, setBadgeSrc] = useState(null);
const [firstCertBadge, setFirstCertBadge] = useState({ color: "purple", imageUrl: null });
useEffect(() => {
const asset = myTier?.plan?.category?.badgeAsset ?? myTier?.category?.badgeAsset
if (!asset) { setBadgeSrc(null); return }
if (asset.storage_provider !== 's3') { setBadgeSrc(asset.file_url ?? null); return }
api.post('/client/media/token', { asset_id: asset.asset_id })
.then(({ data }) => {
const base = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '')
setBadgeSrc(`${base}/client/media/stream/${data?.data?.token}`)
})
.catch(() => setBadgeSrc(null))
}, [myTier])
const [inProgressCourses, setInProgressCourses] = useState([]); const [inProgressCourses, setInProgressCourses] = useState([]);
const [inProgressCoursesLoading, setInProgressCoursesLoading] = useState(false); const [inProgressCoursesLoading, setInProgressCoursesLoading] = useState(false);
@@ -222,7 +222,7 @@ const ProfilePage = () => {
// ── Derived ──────────────────────────────────────────────────────────────── // ── Derived ────────────────────────────────────────────────────────────────
const tier = myTier?.tier ?? user?.tier ?? "free"; const tier = myTier?.tier ?? user?.tier ?? "free";
const tierBadge = resolveTierBadge(myTier); const tierBadge = { ...resolveTierBadge(myTier), ...(badgeSrc ? { src: badgeSrc } : {}) };
const earlyAccessBadge = resolveEarlyAccessBadge(systemBadges); const earlyAccessBadge = resolveEarlyAccessBadge(systemBadges);
const displayName = fullName || user?.personal_info?.name?.full_name || user?.email?.split("@")[0] || "—"; const displayName = fullName || user?.personal_info?.name?.full_name || user?.email?.split("@")[0] || "—";
const initials = displayName.split(" ").map((w) => w[0]).join("").slice(0, 2).toUpperCase(); const initials = displayName.split(" ").map((w) => w[0]).join("").slice(0, 2).toUpperCase();
@@ -232,6 +232,34 @@ const ProfilePage = () => {
const certAchievements = achievements.filter((a) => a.key.startsWith("course_completed_")); const certAchievements = achievements.filter((a) => a.key.startsWith("course_completed_"));
// Fetch badge color/image for the first certificate shown in the profile preview
useEffect(() => {
if (!certAchievements.length) return;
const first = certAchievements[0];
const uuid = first.metadata?.courseUuid ?? first.key.replace("course_completed_", "");
if (!uuid) return;
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
api.get(`/client/courses/uuid/${uuid}`)
.then(({ data }) => {
const c = data.data ?? data;
if (!c) return;
if (c.badge_asset_id) {
api.post("/client/media/token", { asset_id: c.badge_asset_id })
.then(({ data: td }) => {
const token = td.data?.token;
setFirstCertBadge({
color: c.badge_color ?? "purple",
imageUrl: token ? `${STREAM_BASE}/${token}` : null,
});
})
.catch(() => setFirstCertBadge({ color: c.badge_color ?? "purple", imageUrl: null }));
} else {
setFirstCertBadge({ color: c.badge_color ?? "purple", imageUrl: c.badge_image_url ?? null });
}
})
.catch(() => {});
}, [certAchievements.length]);
const hasEarlyAccess = achievements.some((a) => a.key === "early_access"); const hasEarlyAccess = achievements.some((a) => a.key === "early_access");
// Premium badge only shows when currently exclusive (went through premium to get here) // Premium badge only shows when currently exclusive (went through premium to get here)
const hasPremiumBadge = achievements.some((a) => a.key === "premium_first_time") && userRank >= 2; const hasPremiumBadge = achievements.some((a) => a.key === "premium_first_time") && userRank >= 2;
@@ -612,6 +640,8 @@ const ProfilePage = () => {
courseTitle={certAchievements[0].description} courseTitle={certAchievements[0].description}
issuedAt={certAchievements[0].granted_at} issuedAt={certAchievements[0].granted_at}
courseUuid={certAchievements[0].metadata?.courseUuid ?? certAchievements[0].key.replace("course_completed_", "")} courseUuid={certAchievements[0].metadata?.courseUuid ?? certAchievements[0].key.replace("course_completed_", "")}
badgeColor={firstCertBadge.color}
badgeImageUrl={firstCertBadge.imageUrl}
/> />
</CardContent> </CardContent>
)} )}
+77 -40
View File
@@ -1,9 +1,8 @@
import { useState, useCallback, useEffect, useRef } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb"; import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useParams, useNavigate, useLocation, useBlocker } from "react-router-dom"; import { useParams, useNavigate, useLocation, useBlocker } from "react-router-dom";
import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle, Zap, ListChecks } from "lucide-react"; import { House, TableOfContents, ArrowRight, ClipboardList, GraduationCap, Trophy, Lock, CheckCircle2, Circle, Zap, ListChecks, ChevronsLeft, ChevronsRight } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ButtonGroup } from "@/components/ui/button-group";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@@ -196,7 +195,7 @@ const SidebarContent = ({
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" /> ? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
: <Circle className="size-4 text-muted-foreground/40 shrink-0" /> : <Circle className="size-4 text-muted-foreground/40 shrink-0" />
} }
<span className="truncate">Unit {index + 1}: {unit.title}</span> <span className="truncate lg:w-40">Unit {index + 1}: {unit.title}</span>
</span> </span>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="pb-1"> <AccordionContent className="pb-1">
@@ -216,7 +215,7 @@ const SidebarContent = ({
? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" /> ? <CheckCircle2 className="size-3.5 text-emerald-500 shrink-0" />
: <Circle className="size-3.5 text-muted-foreground/30 shrink-0" /> : <Circle className="size-3.5 text-muted-foreground/30 shrink-0" />
} }
<span className="truncate">{lesson.title}</span> <span className="truncate lg:w-40">{lesson.title}</span>
</li> </li>
); );
})} })}
@@ -241,7 +240,7 @@ const SidebarContent = ({
? <Lock className="size-3.5 text-amber-500 shrink-0" /> ? <Lock className="size-3.5 text-amber-500 shrink-0" />
: <ClipboardList className="size-3.5 shrink-0" /> : <ClipboardList className="size-3.5 shrink-0" />
} }
{unit.quiz.title || "Quiz"} {"Quiz"}
</li> </li>
); );
})()} })()}
@@ -264,7 +263,7 @@ const SidebarContent = ({
? <Lock className="size-4 shrink-0 text-amber-500" /> ? <Lock className="size-4 shrink-0 text-amber-500" />
: <GraduationCap className="size-4 shrink-0 text-muted-foreground" /> : <GraduationCap className="size-4 shrink-0 text-muted-foreground" />
} }
{courseAssessment.title || "Course Assessment"} Assessment
</div> </div>
</div> </div>
)} )}
@@ -690,6 +689,12 @@ const UnitList = () => {
const units = course?.units ?? []; const units = course?.units ?? [];
const nextContent = getNextContent(); const nextContent = getNextContent();
const nextLabel = nextContent
? nextContent.type === "lesson" ? nextContent.lesson.title
: nextContent.type === "quiz" ? (nextContent.quiz.title || "Quiz")
: (nextContent.assessment?.title || "Course Assessment")
: null;
const showSidebarContent = desktopSidebarOpen && !quizSessionActive;
const handleNextContentClick = () => { const handleNextContentClick = () => {
if (!nextContent) return; if (!nextContent) return;
@@ -757,7 +762,7 @@ const UnitList = () => {
{/* ── Task-mode banner ─────────────────────────────────────────── */} {/* ── Task-mode banner ─────────────────────────────────────────── */}
{taskCtx?.has_task && ( {taskCtx?.has_task && (
<div className={`fixed top-[124px] left-0 right-0 z-30 flex items-center gap-2 text-white text-xs font-medium px-4 py-1.5 shadow-sm transition-colors ${ <div className={`fixed top-[112px] left-0 right-0 z-30 flex items-center gap-2 text-white text-xs font-medium px-4 py-1.5 shadow-sm transition-colors ${
course?.is_completed ? 'bg-green-600' : 'bg-blue-600' course?.is_completed ? 'bg-green-600' : 'bg-blue-600'
}`}> }`}>
<ListChecks className="size-3.5 shrink-0" /> <ListChecks className="size-3.5 shrink-0" />
@@ -788,6 +793,24 @@ const UnitList = () => {
</div> </div>
)} )}
{/* ── Up next after passing a quiz ── */}
{selectedQuizId && quiz?.has_passed && !quizSessionActive && nextContent && (
<div className="fixed bottom-6 right-6 z-20 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div
onClick={handleNextContentClick}
className="flex items-center gap-3 bg-card border rounded-xl dark:hover:border-blue-500 dark:hover:shadow-blue-500 px-4 py-3 shadow-xl hover:bg-muted transition-colors text-sm font-medium cursor-pointer select-none"
>
<div className="flex flex-col items-start">
<span className="text-sm text-muted-foreground font-normal">Up next</span>
{nextContent.type === "lesson" ? nextContent.lesson.title
: nextContent.type === "quiz" ? (nextContent.quiz.title || "Quiz")
: (nextContent.assessment?.title || "Course Assessment")}
</div>
<ArrowRight className="size-4 text-muted-foreground" />
</div>
</div>
)}
{/* ── Top sticky bar ── */} {/* ── Top sticky bar ── */}
<div className="fixed top-[67px] left-0 right-0 z-40 bg-card border-b py-3 px-4 md:px-6"> <div className="fixed top-[67px] left-0 right-0 z-40 bg-card border-b py-3 px-4 md:px-6">
<div className="w-full flex items-center justify-between gap-2"> <div className="w-full flex items-center justify-between gap-2">
@@ -835,17 +858,6 @@ const UnitList = () => {
</SheetContent> </SheetContent>
</Sheet> </Sheet>
</div> </div>
{/* Desktop TOC toggle */}
<ButtonGroup id="call-to-action" className="flex-shrink-0">
<Button
variant="outline"
className="hidden lg:flex"
onClick={() => setDesktopSidebarOpen((prev) => !prev)}
>
<TableOfContents />
</Button>
</ButtonGroup>
</div> </div>
{/* Scroll progress bar */} {/* Scroll progress bar */}
@@ -859,31 +871,52 @@ const UnitList = () => {
)} )}
</div> </div>
{/* ── Desktop sidebar ── */} {/* ── Desktop sidebar: rail + collapsible content panel (desktop only) ── */}
{desktopSidebarOpen && ( <div className={`hidden lg:flex flex-row fixed ${taskCtx?.has_task ? "top-[140px]" : "top-[112px]"} bottom-0 left-0 z-30 bg-muted border-r`}>
<div className={`hidden lg:block fixed ${taskCtx?.has_task ? "top-[148px]" : "top-[124px]"} bottom-0 left-0 w-80 bg-muted border-r`}> {/* Rail — always visible */}
<SidebarContent <div className="w-14 shrink-0 flex flex-col items-center pt-3">
units={units} <Button
selectedLessonId={selectedLessonId} variant="ghost"
selectedQuizId={selectedQuizId} size="icon"
onLessonClick={handleLessonClick} className="size-8"
onQuizClick={handleQuizClick} onClick={() => setDesktopSidebarOpen((prev) => !prev)}
courseAssessment={course?.assessment} disabled={quizSessionActive}
selectedAssessment={selectedAssessment} title={showSidebarContent ? "Collapse sidebar" : "Expand sidebar"}
onAssessmentClick={handleAssessmentClick} >
assessmentLocked={!allRequiredQuizzesPassed} {showSidebarContent
isCompleted={course?.is_completed} ? <ChevronsLeft className="size-4" />
selectedCompletion={selectedCompletion} : <ChevronsRight className="size-4" />
onCompletionClick={handleCompletionClick} }
getLessonCompleted={(l) => isProgressCompleted(l.uuid)} </Button>
getUnitCompleted={(u) => isProgressCompleted(u.uuid)}
loading={courseLoading}
/>
</div> </div>
)}
{/* Content panel — hidden during quiz / assessment session */}
{showSidebarContent && (
<div className="w-[266px] border-l overflow-hidden">
<SidebarContent
units={units}
selectedLessonId={selectedLessonId}
selectedQuizId={selectedQuizId}
onLessonClick={handleLessonClick}
onQuizClick={handleQuizClick}
courseAssessment={course?.assessment}
selectedAssessment={selectedAssessment}
onAssessmentClick={handleAssessmentClick}
assessmentLocked={!allRequiredQuizzesPassed}
isCompleted={course?.is_completed}
selectedCompletion={selectedCompletion}
onCompletionClick={handleCompletionClick}
getLessonCompleted={(l) => isProgressCompleted(l.uuid)}
getUnitCompleted={(u) => isProgressCompleted(u.uuid)}
isQuizLocked={(u) => lockedQuizUnitIds.has(u.unit_id)}
loading={courseLoading}
/>
</div>
)}
</div>
{/* ── Main content ── */} {/* ── Main content ── */}
<div className={`${taskCtx?.has_task ? "mt-[8.5rem]" : "mt-32"} ${desktopSidebarOpen ? "lg:ml-80" : "lg:ml-0"} p-4 md:p-6 min-h-screen`}> <div className={`${taskCtx?.has_task ? "mt-[9.5rem]" : "mt-32"} ${showSidebarContent ? "lg:ml-80" : "lg:ml-14"} p-4 md:p-6 min-h-screen`}>
<div className="relative w-full h-full"> <div className="relative w-full h-full">
{selectedCompletion ? ( {selectedCompletion ? (
<CourseCompleteBlock course={course} /> <CourseCompleteBlock course={course} />
@@ -909,6 +942,8 @@ const UnitList = () => {
}} }}
onRetake={() => getCourseAssessment(courseId)} onRetake={() => getCourseAssessment(courseId)}
onActiveChange={setQuizActive} onActiveChange={setQuizActive}
onNextContent={nextContent ? handleNextContentClick : undefined}
nextLabel={nextLabel}
/> />
) )
) : selectedQuizId ? ( ) : selectedQuizId ? (
@@ -930,6 +965,8 @@ const UnitList = () => {
}} }}
onRetake={() => getUnitQuiz(courseId, selectedUnitId)} onRetake={() => getUnitQuiz(courseId, selectedUnitId)}
onActiveChange={setQuizActive} onActiveChange={setQuizActive}
onNextContent={nextContent ? handleNextContentClick : undefined}
nextLabel={nextLabel}
/> />
) )
) : ( ) : (
+242 -161
View File
@@ -1,27 +1,30 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import {
Card, CardContent, CardHeader, CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import {
ArrowLeft, BookOpen, Clock, Check, ArrowLeft, BookOpen, Clock, Check,
Tag, LockIcon, Zap, CalendarDays, Tag, LockIcon, Zap, CalendarDays,
Star, Users, Trophy, Shield, Flame,
} from "lucide-react"; } from "lucide-react";
import { useClientTiers } from "@/contexts/ClientTiersProvider"; import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { useProfile } from "@/contexts/ProfileProvider";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import { useCurrency } from "@/hooks/useCurrency";
import { useCurrencyPreference } from "@/contexts/CurrencyPreferenceContext";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function formatDuration(days) { const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
function formatDuration(days, unit) {
if (!days) return null; if (!days) return null;
if (days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""}`; const multiplier = UNIT_TO_DAYS[unit] ?? 1;
if (days % 30 === 0) return `${days / 30} month${days / 30 > 1 ? "s" : ""}`; const value = Math.round((days / multiplier) * 1000) / 1000;
return `${days} days`; const label = unit ?? "day";
return `${value} ${label}${value !== 1 ? "s" : ""}`;
} }
function formatCourseDuration(seconds = 0) { function formatCourseDuration(seconds = 0) {
@@ -29,31 +32,60 @@ function formatCourseDuration(seconds = 0) {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);
if (h && m) return `${h}h ${m}m`; if (h && m) return `${h}h ${m}m`;
if (h) return `${h}h`; if (h) return `${h}h`;
return `${m}m`; return `${m}m`;
} }
// ─── Tier config ──────────────────────────────────────────────────────────────
const TIER_STYLES = { const TIER_STYLES = {
free: { free: {
badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white", badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white",
banner: "from-lime-50 to-green-50 dark:from-lime-950/30 dark:to-green-950/30 border-lime-200 dark:border-lime-800", heroBg: "from-lime-500 via-green-600 to-emerald-700",
icon: Tag, accentColor: "text-lime-600 dark:text-lime-400",
label: "Free", accentBg: "bg-lime-50 dark:bg-lime-950/30",
button: "default", accentBorder: "border-lime-200 dark:border-lime-800",
icon: Tag,
label: "Free",
tagline: "Start your learning journey — no cost, no commitment.",
perks: [
{ icon: BookOpen, text: "Access to free course library" },
{ icon: Users, text: "Join our learning community" },
{ icon: Shield, text: "Track your progress & achievements" },
{ icon: Star, text: "No credit card required" },
],
}, },
premium: { premium: {
badge: "bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white", badge: "bg-gradient-to-r from-fuchsia-500 to-purple-600 text-white",
banner: "from-fuchsia-50 to-purple-50 dark:from-fuchsia-950/30 dark:to-purple-950/30 border-fuchsia-200 dark:border-fuchsia-800", heroBg: "from-fuchsia-600 via-purple-700 to-violet-800",
icon: Zap, accentColor: "text-fuchsia-600 dark:text-fuchsia-400",
label: "Premium", accentBg: "bg-fuchsia-50 dark:bg-fuchsia-950/30",
button: "default", accentBorder: "border-fuchsia-200 dark:border-fuchsia-800",
icon: Zap,
label: "Premium",
tagline: "Unlock expert knowledge and accelerate your career.",
perks: [
{ icon: BookOpen, text: "Full access to all premium courses" },
{ icon: Clock, text: "Learn at your own pace, anytime" },
{ icon: Trophy, text: "Earn certificates of completion" },
{ icon: Shield, text: "Priority support & guidance" },
],
}, },
exclusive: { exclusive: {
badge: "bg-gradient-to-r from-rose-500 to-red-600 text-white", badge: "bg-gradient-to-r from-rose-500 to-red-600 text-white",
banner: "from-rose-50 to-red-50 dark:from-rose-950/30 dark:to-red-950/30 border-rose-200 dark:border-rose-800", heroBg: "from-rose-600 via-red-700 to-orange-800",
icon: LockIcon, accentColor: "text-rose-600 dark:text-rose-400",
label: "Exclusive", accentBg: "bg-rose-50 dark:bg-rose-950/30",
button: "default", accentBorder: "border-rose-200 dark:border-rose-800",
icon: LockIcon,
label: "Exclusive",
tagline: "The ultimate learning experience for serious professionals.",
perks: [
{ icon: Star, text: "Everything in Premium unlocked" },
{ icon: Users, text: "1-on-1 mentorship sessions" },
{ icon: Trophy, text: "Exclusive expert-only content" },
{ icon: Flame, text: "Early access to new releases" },
],
}, },
}; };
@@ -61,9 +93,9 @@ const TIER_STYLES = {
const ViewPlanSkeleton = () => ( const ViewPlanSkeleton = () => (
<div className="mt-17 bg-muted min-h-screen"> <div className="mt-17 bg-muted min-h-screen">
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-4"> <Skeleton className="h-72 w-full" />
<Skeleton className="h-8 w-32" /> <div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-4 mt-4">
<Skeleton className="h-40 w-full rounded-2xl" /> <Skeleton className="h-28 w-full rounded-2xl" />
<Skeleton className="h-64 w-full rounded-2xl" /> <Skeleton className="h-64 w-full rounded-2xl" />
</div> </div>
</div> </div>
@@ -75,173 +107,222 @@ const ViewPlan = () => {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { myTier, getMyTier, plans, plansLoading, getPlans } = useClientTiers(); const { myTier, getMyTier, plans, plansLoading, getPlans } = useClientTiers();
const { fmtCurrency } = useDateFormat(); const { profile, getProfile } = useProfile();
const { fmtPlanPrice } = useCurrency();
const { currency, setCurrency } = useCurrencyPreference();
useEffect(() => { useEffect(() => {
getProfile();
getMyTier(); getMyTier();
if (!plans.length) getPlans(); if (!plans.length) getPlans();
}, [id]); }, [id]);
const plan = plans.find((p) => String(p.plan_id) === String(id)) ?? null; // Seed currency preference from the user's stored profile
const loading = plansLoading; useEffect(() => {
if (profile?.preferred_currency && profile.preferred_currency !== currency) {
setCurrency(profile.preferred_currency);
}
}, [profile?.preferred_currency]); // eslint-disable-line react-hooks/exhaustive-deps
const plan = plans.find((p) => String(p.plan_id) === String(id)) ?? null;
const loading = plansLoading;
if (loading) return <ViewPlanSkeleton />; if (loading) return <ViewPlanSkeleton />;
if (!plan) return null; if (!plan) return null;
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free; const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
const Icon = style.icon; const Icon = style.icon;
const duration = formatDuration(plan.duration_days); const duration = formatDuration(plan.duration_days, plan.duration_unit);
const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active"; const isCurrent = myTier?.tier === plan.tier && myTier?.status === "active";
const totalSeconds = (plan.courses ?? []).reduce((sum, c) => sum + (c.duration_seconds ?? 0), 0); const totalSeconds = (plan.courses ?? []).reduce((sum, c) => sum + (c.duration_seconds ?? 0), 0);
const totalDuration = formatCourseDuration(totalSeconds); const totalDuration = formatCourseDuration(totalSeconds);
return ( return (
<div className="mt-17 bg-muted min-h-screen"> <div className="mt-17 bg-muted min-h-screen">
<PageMeta title={plan ? `${plan.label} - STARR` : undefined} /> <PageMeta title={plan ? `${plan.label} - STARR` : undefined} />
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-4">
{/* Back */} {/* ── Hero Banner ───────────────────────────────────────────── */}
<div className="flex items-center gap-3"> <div className={`relative bg-gradient-to-br ${style.heroBg} overflow-hidden`}>
<Button variant="ghost" size="icon" onClick={() => navigate("/plans")}> {/* Decorative blobs */}
<ArrowLeft className="size-4" /> <div className="absolute inset-0 pointer-events-none overflow-hidden">
</Button> <div className="absolute -top-24 -right-24 w-96 h-96 rounded-full bg-white/5" />
<div> <div className="absolute -bottom-16 -left-16 w-72 h-72 rounded-full bg-white/5" />
<h1 className="text-base font-semibold">Plan Details</h1> <div className="absolute top-1/2 left-1/3 w-48 h-48 rounded-full bg-white/5" />
<p className="text-xs text-muted-foreground">Review what's included before subscribing</p> </div>
<div className="relative px-6 pt-8 pb-10 lg:container lg:max-w-3xl lg:mx-auto">
<button
onClick={() => navigate("/plans")}
className="flex items-center gap-1.5 text-white/70 hover:text-white text-sm mb-8 transition-colors"
>
<ArrowLeft className="size-4" /> Back to Plans
</button>
<Badge className={`${style.badge} mb-4 text-sm px-3 py-1`}>
<Icon className="size-3.5 mr-1" /> {style.label}
</Badge>
<h1 className="text-3xl sm:text-4xl font-extrabold text-white mb-2 leading-tight tracking-tight">
{plan.label}
</h1>
<p className="text-white/70 text-sm mb-8 max-w-md leading-relaxed">
{plan.description || style.tagline}
</p>
<div className="flex flex-wrap items-end gap-4">
<div>
<span className="text-5xl font-black text-white leading-none">
{fmtPlanPrice(plan)}
</span>
{duration && (
<span className="text-white/60 text-sm ml-2">/ {duration}</span>
)}
</div>
{isCurrent ? (
<Badge className="bg-white/20 border border-white/30 text-white px-4 py-2 text-sm font-medium">
<Check className="size-3.5 mr-1" /> Your Current Plan
</Badge>
) : !plan.is_active ? (
<Badge className="bg-white/20 border border-white/30 text-white px-4 py-2 text-sm font-medium">
Not Available at the Moment
</Badge>
) : (
<Button
size="lg"
className="bg-white text-gray-900 hover:bg-white/90 font-bold shadow-xl"
onClick={() => navigate(`/plans/checkout?plan_id=${plan.plan_id}`)}
>
Get Started →
</Button>
)}
</div>
</div>
</div>
<div className="px-6 lg:container lg:max-w-3xl lg:mx-auto space-y-5 py-6">
{/* ── What's included ───────────────────────────────────── */}
<div className={`rounded-2xl border ${style.accentBorder} ${style.accentBg} p-5`}>
<p className={`text-xs font-bold uppercase tracking-widest ${style.accentColor} mb-4`}>
What's included
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{style.perks.map(({ icon: PerkIcon, text }, i) => (
<div key={i} className="flex items-center gap-3">
<div className={`h-8 w-8 rounded-lg ${style.accentBg} border ${style.accentBorder} flex items-center justify-center shrink-0`}>
<PerkIcon className={`size-4 ${style.accentColor}`} />
</div>
<span className="text-sm font-medium">{text}</span>
</div>
))}
</div> </div>
</div> </div>
{/* Plan banner */} {/* ── Stats row ─────────────────────────────────────────── */}
<Card className={`bg-gradient-to-r ${style.banner} border`}> <div className="grid grid-cols-3 gap-3">
<CardContent className="py-6 px-6"> {[
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> { label: "Courses", value: plan.course_count ?? 0, icon: BookOpen },
<div className="space-y-2"> { label: "Content", value: totalDuration ?? "—", icon: Clock },
<Badge className={style.badge}> { label: "Access", value: duration ?? "Lifetime", icon: CalendarDays },
<Icon className="size-3.5" /> {style.label} ].map(({ label, value, icon: StatIcon }) => (
</Badge> <div key={label} className="rounded-xl bg-card border p-4 flex flex-col items-center gap-1 text-center">
<h2 className="text-2xl font-bold">{plan.label}</h2> <StatIcon className={`size-4 ${style.accentColor}`} />
<div className="flex items-center gap-2 flex-wrap"> <p className="text-2xl font-bold leading-none mt-1">{value}</p>
<span className="text-3xl font-bold"> <p className="text-xs text-muted-foreground">{label}</p>
{fmtCurrency(plan.price, plan.currency)}
</span>
{duration && (
<span className="text-sm text-muted-foreground flex items-center gap-1">
<CalendarDays className="size-3.5" /> {duration}
</span>
)}
</div>
</div>
{isCurrent ? (
<Badge className="bg-green-500 text-white self-start sm:self-center px-4 py-2 text-sm">
<Check className="size-3.5" /> Current Plan
</Badge>
) : (
<Button
size="lg"
className="self-start sm:self-center"
onClick={() => navigate(`/plans/checkout?plan_id=${plan.plan_id}`)}
>
Get {style.label} Plan
</Button>
)}
</div> </div>
</CardContent> ))}
</Card>
{/* Summary stats */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<Card>
<CardContent className=" flex flex-col gap-1">
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<BookOpen className="size-3.5" /> Courses
</p>
<p className="text-2xl font-bold">{plan.course_count ?? 0}</p>
</CardContent>
</Card>
<Card>
<CardContent className=" flex flex-col gap-1">
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Clock className="size-3.5" /> Total Duration
</p>
<p className="text-2xl font-bold">{totalDuration ?? "—"}</p>
</CardContent>
</Card>
<Card>
<CardContent className=" flex flex-col gap-1">
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<CalendarDays className="size-3.5" /> Access
</p>
<p className="text-2xl font-bold">{duration ?? "Lifetime"}</p>
</CardContent>
</Card>
</div> </div>
{/* Included courses */} {/* ── Included Courses ──────────────────────────────────── */}
<Card> <div className="rounded-2xl bg-card border overflow-hidden">
<CardHeader className="pb-2"> <div className="px-5 py-4 border-b flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2"> <div className="flex items-center gap-2">
<BookOpen className="size-4" /> <BookOpen className={`size-4 ${style.accentColor}`} />
Included Courses <span className="text-sm font-semibold">Included Courses</span>
<Badge variant="secondary">{plan.course_count ?? 0}</Badge> </div>
</CardTitle> <Badge variant="secondary">{plan.course_count ?? 0}</Badge>
</CardHeader> </div>
<CardContent className="space-y-3">
<div className="divide-y">
{plan.courses?.length > 0 ? ( {plan.courses?.length > 0 ? (
plan.courses.map((course, i) => ( plan.courses.map((course) => (
<div key={course.course_id}> <div key={course.course_id} className="flex items-start gap-4 px-5 py-4">
{i > 0 && <Separator className="mb-3" />} <div className={`h-10 w-10 rounded-xl ${style.accentBg} border ${style.accentBorder} flex items-center justify-center shrink-0`}>
<div className="flex items-start gap-3"> <BookOpen className={`size-5 ${style.accentColor}`} />
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center shrink-0"> </div>
<BookOpen className="size-4 text-secondary-foreground" /> <div className="flex-1 min-w-0">
<p className="text-sm font-semibold line-clamp-1">{course.title}</p>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{course.level && (
<Badge variant="outline" className="text-xs capitalize h-5">
{course.level}
</Badge>
)}
{formatCourseDuration(course.duration_seconds) && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="size-3" />
{formatCourseDuration(course.duration_seconds)}
</span>
)}
{course.course_code && (
<span className="text-xs text-muted-foreground">{course.course_code}</span>
)}
</div> </div>
<div className="flex-1 min-w-0"> </div>
<p className="text-sm font-medium line-clamp-2">{course.title}</p> <div className="h-6 w-6 rounded-full bg-green-100 dark:bg-green-950 flex items-center justify-center shrink-0">
<div className="flex items-center gap-3 mt-1 flex-wrap"> <Check className="size-3.5 text-green-600 dark:text-green-400" />
{course.level && (
<Badge variant="outline" className="text-xs capitalize h-5">
{course.level}
</Badge>
)}
{formatCourseDuration(course.duration_seconds) && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="size-3" />
{formatCourseDuration(course.duration_seconds)}
</span>
)}
{course.course_code && (
<span className="text-xs text-muted-foreground">
{course.course_code}
</span>
)}
</div>
</div>
<Check className="size-4 text-green-500 shrink-0 mt-0.5" />
</div> </div>
</div> </div>
)) ))
) : ( ) : (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground/50"> <div className="flex flex-col items-center justify-center py-10 text-center px-6">
<BookOpen className="size-8 mb-2" /> <div className={`h-14 w-14 rounded-2xl ${style.accentBg} border ${style.accentBorder} flex items-center justify-center mb-3`}>
<p className="text-sm">No courses assigned to this plan yet.</p> <BookOpen className={`size-7 ${style.accentColor}`} />
</div>
<p className="font-semibold text-sm">Courses coming soon</p>
<p className="text-xs text-muted-foreground mt-1 max-w-xs">
We're curating the best content for this plan. Subscribe now and get instant access the moment they go live.
</p>
</div> </div>
)} )}
</CardContent> </div>
</Card> </div>
{/* Bottom CTA */} {/* ── Bottom CTA ────────────────────────────────────────── */}
{!isCurrent && ( {!isCurrent && (
<div className="flex justify-end gap-2 pb-6"> <div className={`rounded-2xl bg-gradient-to-br ${style.heroBg} p-6 text-center relative overflow-hidden`}>
<Button variant="outline" onClick={() => navigate("/plans")}> <div className="absolute inset-0 pointer-events-none overflow-hidden">
Back to Plans <div className="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-white/5" />
</Button> <div className="absolute -bottom-8 -left-8 w-32 h-32 rounded-full bg-white/5" />
<Button </div>
<div className="relative">
onClick={() => navigate(`/plans/checkout?plan_id=${plan.plan_id}`)} <p className="text-white font-bold text-lg mb-1">
> {plan.is_active ? "Ready to get started?" : "Coming Soon"}
Get {style.label} Plan — {fmtCurrency(plan.price, plan.currency)} </p>
</Button> <p className="text-white/70 text-sm mb-5 max-w-xs mx-auto">
{plan.is_active ? style.tagline : "This plan is not available for purchase at the moment. Check back later."}
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button
variant="ghost"
className="text-white/80 hover:text-white hover:bg-white/10"
onClick={() => navigate("/plans")}
>
View All Plans
</Button>
{plan.is_active && (
<Button
size="lg"
className="bg-white text-gray-900 hover:bg-white/90 font-bold shadow-lg"
onClick={() => navigate(`/plans/checkout?plan_id=${plan.plan_id}`)}
>
Get {style.label} Plan — {fmtPlanPrice(plan)}
</Button>
)}
</div>
</div>
</div> </div>
)} )}
@@ -250,4 +331,4 @@ const ViewPlan = () => {
); );
}; };
export default ViewPlan; export default ViewPlan;
+14
View File
@@ -0,0 +1,14 @@
// Static mirror of the backend ACHIEVEMENT_REGISTRY.
// Keep in sync with new_starr/data/achievements.data.js.
export const ACHIEVEMENT_REGISTRY = [
{ key: "early_access", type: "badge", label: "Early Access", description: "Registered during the Philproperties beta period." },
{ key: "premium_first_time", type: "badge", label: "Premium Member", description: "Purchased a Premium tier plan for the first time." },
{ key: "exclusive_first_time", type: "badge", label: "Exclusive Member", description: "Purchased an Exclusive tier plan for the first time." },
{ key: "first_course_completed", type: "milestone", label: "First Course Completed", description: "Completed your very first course on Philproperties." },
{ key: "courses_completed_5", type: "milestone", label: "Learning Streak", description: "Completed 5 courses." },
{ key: "courses_completed_10", type: "milestone", label: "Knowledge Builder", description: "Completed 10 courses." },
{ key: "perfect_quiz_score", type: "milestone", label: "Perfect Score", description: "Achieved a perfect score on a quiz." },
{ key: "profile_completed", type: "milestone", label: "Profile Complete", description: "Filled out all personal profile information." },
{ key: "first_referral", type: "milestone", label: "Referral Champion", description: "Successfully referred a user to Philproperties." },
];
+110
View File
@@ -79,6 +79,116 @@ export const TIER_COLOR_MAP = {
badge: "bg-gradient-to-r from-slate-600 to-slate-800 text-white border-0", badge: "bg-gradient-to-r from-slate-600 to-slate-800 text-white border-0",
panel: { bg: "bg-slate-50 dark:bg-slate-950/30", border: "border-slate-200 dark:border-slate-700" }, panel: { bg: "bg-slate-50 dark:bg-slate-950/30", border: "border-slate-200 dark:border-slate-700" },
}, },
// ── Extended palette (18 more → 30 total) ───────────────────────────────────
red: {
label: "Red",
swatch: "#ef4444",
badge: "bg-gradient-to-r from-red-500 to-red-700 text-white border-0",
panel: { bg: "bg-red-50 dark:bg-red-950/30", border: "border-red-200 dark:border-red-800" },
},
yellow: {
label: "Yellow",
swatch: "#eab308",
badge: "bg-gradient-to-r from-yellow-400 to-yellow-600 text-white border-0",
panel: { bg: "bg-yellow-50 dark:bg-yellow-950/30", border: "border-yellow-200 dark:border-yellow-800" },
},
violet: {
label: "Violet",
swatch: "#7c3aed",
badge: "bg-gradient-to-r from-violet-500 to-purple-700 text-white border-0",
panel: { bg: "bg-violet-50 dark:bg-violet-950/30", border: "border-violet-200 dark:border-violet-800" },
},
fuchsia: {
label: "Fuchsia",
swatch: "#d946ef",
badge: "bg-gradient-to-r from-fuchsia-500 to-pink-700 text-white border-0",
panel: { bg: "bg-fuchsia-50 dark:bg-fuchsia-950/30", border: "border-fuchsia-200 dark:border-fuchsia-800" },
},
emerald: {
label: "Emerald",
swatch: "#10b981",
badge: "bg-gradient-to-r from-emerald-400 to-emerald-700 text-white border-0",
panel: { bg: "bg-emerald-50 dark:bg-emerald-950/30", border: "border-emerald-200 dark:border-emerald-800" },
},
blue: {
label: "Blue",
swatch: "#3b82f6",
badge: "bg-gradient-to-r from-blue-500 to-blue-700 text-white border-0",
panel: { bg: "bg-blue-50 dark:bg-blue-950/30", border: "border-blue-200 dark:border-blue-800" },
},
zinc: {
label: "Zinc",
swatch: "#71717a",
badge: "bg-gradient-to-r from-zinc-500 to-zinc-700 text-white border-0",
panel: { bg: "bg-zinc-50 dark:bg-zinc-950/30", border: "border-zinc-200 dark:border-zinc-800" },
},
stone: {
label: "Stone",
swatch: "#78716c",
badge: "bg-gradient-to-r from-stone-500 to-stone-700 text-white border-0",
panel: { bg: "bg-stone-50 dark:bg-stone-950/30", border: "border-stone-200 dark:border-stone-800" },
},
brown: {
label: "Brown",
swatch: "#a16207",
badge: "bg-gradient-to-r from-yellow-700 to-amber-900 text-white border-0",
panel: { bg: "bg-amber-50 dark:bg-amber-950/30", border: "border-amber-300 dark:border-amber-900" },
},
gold: {
label: "Gold",
swatch: "#ca8a04",
badge: "bg-gradient-to-r from-yellow-500 to-amber-700 text-white border-0",
panel: { bg: "bg-yellow-50 dark:bg-yellow-950/30", border: "border-yellow-300 dark:border-yellow-800" },
},
navy: {
label: "Navy",
swatch: "#1e40af",
badge: "bg-gradient-to-r from-blue-700 to-blue-900 text-white border-0",
panel: { bg: "bg-blue-50 dark:bg-blue-950/30", border: "border-blue-300 dark:border-blue-900" },
},
forest: {
label: "Forest",
swatch: "#166534",
badge: "bg-gradient-to-r from-green-700 to-green-900 text-white border-0",
panel: { bg: "bg-green-50 dark:bg-green-950/30", border: "border-green-300 dark:border-green-900" },
},
wine: {
label: "Wine",
swatch: "#9f1239",
badge: "bg-gradient-to-r from-rose-800 to-rose-950 text-white border-0",
panel: { bg: "bg-rose-50 dark:bg-rose-950/30", border: "border-rose-300 dark:border-rose-900" },
},
charcoal: {
label: "Charcoal",
swatch: "#374151",
badge: "bg-gradient-to-r from-gray-600 to-gray-900 text-white border-0",
panel: { bg: "bg-gray-50 dark:bg-gray-950/30", border: "border-gray-300 dark:border-gray-800" },
},
midnight: {
label: "Midnight",
swatch: "#312e81",
badge: "bg-gradient-to-r from-indigo-800 to-indigo-950 text-white border-0",
panel: { bg: "bg-indigo-50 dark:bg-indigo-950/30", border: "border-indigo-300 dark:border-indigo-900" },
},
lavender: {
label: "Lavender",
swatch: "#a78bfa",
badge: "bg-gradient-to-r from-violet-400 to-violet-600 text-white border-0",
panel: { bg: "bg-violet-50 dark:bg-violet-950/30", border: "border-violet-200 dark:border-violet-700" },
},
salmon: {
label: "Salmon",
swatch: "#fca5a5",
badge: "bg-gradient-to-r from-red-300 to-rose-600 text-white border-0",
panel: { bg: "bg-red-50 dark:bg-red-950/30", border: "border-red-200 dark:border-red-700" },
},
mint: {
label: "Mint",
swatch: "#6ee7b7",
badge: "bg-gradient-to-r from-emerald-300 to-teal-600 text-white border-0",
panel: { bg: "bg-emerald-50 dark:bg-emerald-950/30", border: "border-emerald-200 dark:border-emerald-700" },
},
}; };
/** Ordered list for the color picker UI. */ /** Ordered list for the color picker UI. */