mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
+12
-6
@@ -2,16 +2,18 @@ import { useEffect } from 'react';
|
||||
import { AuthProvider, useAuth, decodeToken } from './contexts/AuthContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { DateTimePreferenceProvider } from './contexts/DateTimePreferenceContext';
|
||||
import { CurrencyPreferenceProvider } from './contexts/CurrencyPreferenceContext';
|
||||
import { Helmet, HelmetProvider } from "react-helmet-async";
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { setAuthInterceptor } from './utils/api.util';
|
||||
import { attachCsrfInterceptor, fetchCsrfToken } from './utils/csrf.util';
|
||||
import AppRouter from './routes/AppRouter';
|
||||
import AppLoadingScreen from './components/AppLoadingScreen';
|
||||
import './index.css';
|
||||
import 'react-photo-view/dist/react-photo-view.css';
|
||||
|
||||
function AppWithAuth() {
|
||||
const { accessTokenRef, setAccessToken, setUser, restoreSession, logout } = useAuth()
|
||||
const { accessTokenRef, setAccessToken, setUser, restoreSession, logout, loading } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
attachCsrfInterceptor()
|
||||
@@ -56,6 +58,8 @@ function AppWithAuth() {
|
||||
restoreSession()
|
||||
}, [])
|
||||
|
||||
if (loading) return <AppLoadingScreen />
|
||||
|
||||
return <AppRouter />
|
||||
}
|
||||
|
||||
@@ -80,11 +84,13 @@ export default function App() {
|
||||
</Helmet>
|
||||
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
|
||||
<DateTimePreferenceProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<AuthProvider>
|
||||
<AppWithAuth />
|
||||
</AuthProvider>
|
||||
</TooltipProvider>
|
||||
<CurrencyPreferenceProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<AuthProvider>
|
||||
<AppWithAuth />
|
||||
</AuthProvider>
|
||||
</TooltipProvider>
|
||||
</CurrencyPreferenceProvider>
|
||||
</DateTimePreferenceProvider>
|
||||
</ThemeProvider>
|
||||
</HelmetProvider>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -125,8 +125,12 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
||||
useEffect(() => {
|
||||
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
|
||||
.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);
|
||||
|
||||
if (!s3Ids.length) return;
|
||||
@@ -135,10 +139,12 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
||||
api.post("/admin/media/tokens", { asset_ids: s3Ids })
|
||||
.then(({ data }) => {
|
||||
if (cancelled) return;
|
||||
const tokens = data.data?.tokens ?? {};
|
||||
const tokens = data.data?.tokens ?? {};
|
||||
const thumbnails = data.data?.thumbnails ?? {};
|
||||
const urls = {};
|
||||
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 }));
|
||||
})
|
||||
@@ -177,7 +183,15 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
||||
|
||||
const handleSelect = (asset) => {
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* disabled? : boolean
|
||||
* placeholder?: string
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import api from '@/utils/api.util';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -60,7 +60,7 @@ export default function GroupMultiSelect({
|
||||
}, [groupsProp]);
|
||||
|
||||
// ── Position portal dropdown under trigger ────────────────────────────────
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !triggerRef.current) return;
|
||||
|
||||
const reposition = () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ function Progress({
|
||||
{...props}>
|
||||
<ProgressPrimitive.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)}%)` }} />
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -942,6 +942,20 @@ export function CoursesProvider({ children }) {
|
||||
}), [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(
|
||||
(field) =>
|
||||
request(async () => {
|
||||
@@ -1112,6 +1126,8 @@ export function CoursesProvider({ children }) {
|
||||
syncCourseCategories,
|
||||
fetchInstructors,
|
||||
syncInstructors,
|
||||
fetchCourseAchievements,
|
||||
syncCourseAchievements,
|
||||
}}>
|
||||
{children}
|
||||
</CoursesContext.Provider>
|
||||
|
||||
@@ -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 { toast } from "sonner";
|
||||
|
||||
@@ -8,6 +8,7 @@ export function ClientTiersProvider({ children }) {
|
||||
// ── My tier
|
||||
const [myTier, setMyTier] = useState(null);
|
||||
const [tierLoading, setTierLoading] = useState(false);
|
||||
const expiryTimerRef = useRef(null);
|
||||
|
||||
// ── Tier history
|
||||
const [tierHistory, setTierHistory] = useState([]);
|
||||
@@ -19,6 +20,7 @@ export function ClientTiersProvider({ children }) {
|
||||
|
||||
// ── Checkout
|
||||
const [checkoutLoading, setCheckoutLoading] = useState(false);
|
||||
const [promoLoading, setPromoLoading] = useState(false);
|
||||
|
||||
// ── My payments
|
||||
const [payments, setPayments] = useState([]);
|
||||
@@ -33,18 +35,43 @@ export function ClientTiersProvider({ children }) {
|
||||
|
||||
// ─── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
const getMyTier = useCallback(async () => {
|
||||
setTierLoading(true);
|
||||
const getMyTier = useCallback(async ({ silent = false } = {}) => {
|
||||
if (!silent) setTierLoading(true);
|
||||
try {
|
||||
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) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load tier.");
|
||||
if (!silent) toast.error(err?.response?.data?.message ?? "Could not load tier.");
|
||||
} 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 () => {
|
||||
setTierHistoryLoading(true);
|
||||
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
|
||||
const createOrder = useCallback(async (plan_id, promo_code = null) => {
|
||||
const createOrder = useCallback(async (plan_id, promo_code = null, currency = null) => {
|
||||
setCheckoutLoading(true);
|
||||
try {
|
||||
const payload = { plan_id };
|
||||
if (promo_code) payload.promo_code = promo_code;
|
||||
if (currency) payload.currency = currency;
|
||||
const { data } = await api.post("/client/tiers/checkout/order", payload);
|
||||
return data.data ?? null;
|
||||
} catch (err) {
|
||||
@@ -92,6 +135,9 @@ export function ClientTiersProvider({ children }) {
|
||||
const { data } = await api.post("/client/tiers/checkout/capture", { order_id });
|
||||
toast.success(data.message ?? "Payment successful. Tier activated.");
|
||||
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;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Payment capture failed.");
|
||||
@@ -99,7 +145,7 @@ export function ClientTiersProvider({ children }) {
|
||||
} finally {
|
||||
setCheckoutLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [getMyTier]);
|
||||
|
||||
const cancelOrder = useCallback(async (order_id) => {
|
||||
if (!order_id) return false;
|
||||
@@ -146,8 +192,11 @@ export function ClientTiersProvider({ children }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// slug → category object — derived, no extra state
|
||||
const tierMap = Object.fromEntries(tierCategories.map((c) => [c.slug, c]));
|
||||
// slug → category object — stable reference; only recomputes when tierCategories array changes
|
||||
const tierMap = useMemo(
|
||||
() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])),
|
||||
[tierCategories]
|
||||
);
|
||||
|
||||
// ─── Reset helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -170,6 +219,7 @@ export function ClientTiersProvider({ children }) {
|
||||
getMyTier,
|
||||
getMyTierHistory,
|
||||
getPlans,
|
||||
validatePromo, promoLoading,
|
||||
createOrder,
|
||||
captureOrder,
|
||||
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;
|
||||
}
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg flex flex-col max-h-[80vh]">
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
{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 />
|
||||
|
||||
{/* ── 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 ? (
|
||||
<div className="space-y-3 py-1">
|
||||
{[...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 { BookOpen, Check, RotateCcw } from "lucide-react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { ChevronsUpDown, Check, BookOpen, AlertTriangle } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
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 { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* CoursePicker
|
||||
*
|
||||
* Props:
|
||||
* subscription — tier slug to filter courses (e.g. "premium"). Pass null/undefined to hide.
|
||||
* selectedIds — Set<string> of selected course_id strings
|
||||
* subscription — tier slug ("premium"). Null/undefined = hidden.
|
||||
* selectedIds — Set<string> of selected course_id strings (managed by parent)
|
||||
* 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 }) {
|
||||
const [courses, setCourses] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
export function CoursePicker({ subscription, selectedIds, onChange, isPreloaded = false }) {
|
||||
const [courses, setCourses] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [bundleAll, setBundleAll] = useState(true);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!subscription) { setCourses([]); return; }
|
||||
if (!subscription) { setCourses([]); setBundleAll(true); return; }
|
||||
setLoading(true);
|
||||
setSearch("");
|
||||
setBundleAll(true); // reset question to "Yes" whenever subscription changes
|
||||
|
||||
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([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [subscription]);
|
||||
}, [subscription]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.toLowerCase();
|
||||
@@ -46,100 +82,170 @@ export function CoursePicker({ subscription, selectedIds, onChange }) {
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const checkAll = () => onChange(new Set(filtered.map((c) => String(c.course_id))));
|
||||
const resetAll = () => onChange(new Set());
|
||||
const checkAll = () => onChange(new Set(courses.map((c) => String(c.course_id))));
|
||||
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;
|
||||
|
||||
if (!subscription) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* ── Bundle question ──────────────────────────────────────────── */}
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-12 w-full rounded-lg" />)}
|
||||
</div>
|
||||
) : 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 className="flex gap-2">
|
||||
<Skeleton className="h-8 w-36" />
|
||||
<Skeleton className="h-8 w-40" />
|
||||
</div>
|
||||
) : (
|
||||
<Command className="rounded-lg border shadow-none" 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>
|
||||
</Command>
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-sm">
|
||||
Bundle <span className="font-semibold capitalize">{subscription}</span> courses with this plan?
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={bundleAll ? "default" : "outline"}
|
||||
onClick={handleBundleAll}
|
||||
disabled={total === 0}
|
||||
>
|
||||
<Check className="size-3.5 mr-1.5" />
|
||||
Yes, include all
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={!bundleAll ? "default" : "outline"}
|
||||
onClick={handleSelectSpecific}
|
||||
disabled={total === 0}
|
||||
>
|
||||
No, choose specific
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Bundle all summary ───────────────────────────────────────── */}
|
||||
{!loading && bundleAll && total > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All {total} <span className="capitalize">{subscription}</span> course{total !== 1 ? "s" : ""} will be included.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── 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">
|
||||
<BookOpen className="size-4 shrink-0" />
|
||||
No <span className="capitalize mx-1 font-medium">{subscription}</span> courses found. Add courses with this subscription first.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 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>
|
||||
);
|
||||
|
||||
@@ -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 { Layers } from "lucide-react";
|
||||
|
||||
@@ -8,7 +8,6 @@ import api from "@/utils/api.util";
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
||||
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
||||
|
||||
import { buildDataColumns, columnPinning } from "../../config/tiers/plans/columns.config";
|
||||
import { buildToolbarActions } from "../../config/tiers/plans/toolbar.config";
|
||||
@@ -22,8 +21,7 @@ export default function TierPlansTable() {
|
||||
|
||||
const {
|
||||
plans, planAttributes, planPagination, setPlanPagination,
|
||||
loading, fetchPlans, deletePlan, restorePlan,
|
||||
bulkDeletePlans, bulkRestorePlans,
|
||||
loading, fetchPlans, deletePlan, bulkDeletePlans,
|
||||
} = useTiers();
|
||||
|
||||
const [hasAvailableCategories, setHasAvailableCategories] = useState(true);
|
||||
@@ -37,11 +35,8 @@ export default function TierPlansTable() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||
const [archiveIds, setArchiveIds] = useState(null);
|
||||
const [restoreIds, setRestoreIds] = useState(null);
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
@@ -52,30 +47,15 @@ export default function TierPlansTable() {
|
||||
|
||||
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 = () => {
|
||||
setArchiveTarget(null);
|
||||
setRestoreTarget(null);
|
||||
setArchiveIds(null);
|
||||
setRestoreIds(null);
|
||||
tableRefsRef.current.resetSelection?.();
|
||||
fetchPlans({
|
||||
page: 1,
|
||||
limit: planPagination?.limit ?? 10,
|
||||
filters: tableRefsRef.current.getFilters(),
|
||||
sort: tableRefsRef.current.getSort(),
|
||||
archived: showArchived,
|
||||
page: 1,
|
||||
limit: planPagination?.limit ?? 10,
|
||||
filters: tableRefsRef.current.getFilters(),
|
||||
sort: tableRefsRef.current.getSort(),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -88,9 +68,7 @@ export default function TierPlansTable() {
|
||||
|
||||
const rowActions = buildRowActions({
|
||||
navigate,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onRestore: (row) => setRestoreTarget(row),
|
||||
showArchived,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
});
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
@@ -98,9 +76,7 @@ export default function TierPlansTable() {
|
||||
pagination: planPagination,
|
||||
exportConfig,
|
||||
navigate,
|
||||
showArchived,
|
||||
hasAvailableCategories,
|
||||
onToggleArchived: handleToggleArchived,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
@@ -108,10 +84,8 @@ export default function TierPlansTable() {
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
showArchived,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onArchiveMany: (ids) => setArchiveIds(ids),
|
||||
onRestoreMany: (ids) => setRestoreIds(ids),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
@@ -174,18 +148,6 @@ export default function TierPlansTable() {
|
||||
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 */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveIds}
|
||||
@@ -197,16 +159,6 @@ export default function TierPlansTable() {
|
||||
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 ────────────────────────────────────────────────────
|
||||
const cellOverrides = {
|
||||
unitCount: (info) => {
|
||||
@@ -38,6 +50,16 @@ const cellOverrides = {
|
||||
</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) => {
|
||||
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 [
|
||||
{
|
||||
key: "view",
|
||||
@@ -13,15 +13,22 @@ export function buildRowActions({ navigate, onArchive, onRestore, showArchived }
|
||||
label: "Edit Plan",
|
||||
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/edit`),
|
||||
hidden: () => showArchived,
|
||||
},
|
||||
{
|
||||
key: "view_units",
|
||||
label: "View Payments",
|
||||
icon: <ShelvingUnit className="h-3.5 w-3.5" />,
|
||||
{
|
||||
key: "payment_policy",
|
||||
label: "Payment Policy",
|
||||
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",
|
||||
onClick: (row) => navigate(`/admin/tiers/payments?plan_id=${row.plan_id}`),
|
||||
separator: true
|
||||
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
@@ -30,15 +37,6 @@ export function buildRowActions({ navigate, onArchive, onRestore, showArchived }
|
||||
className: "text-destructive",
|
||||
onClick: (row) => onArchive(row),
|
||||
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";
|
||||
|
||||
export function buildSelectionActions({
|
||||
exportConfig,
|
||||
showArchived,
|
||||
onArchive,
|
||||
onArchiveMany,
|
||||
onRestoreMany,
|
||||
getTableInstance,
|
||||
}) {
|
||||
return [
|
||||
@@ -21,7 +19,7 @@ export function buildSelectionActions({
|
||||
tableInstance: table ?? getTableInstance(),
|
||||
}),
|
||||
},
|
||||
!showArchived && {
|
||||
{
|
||||
key: "archive-selected",
|
||||
label: "Archive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
@@ -31,15 +29,5 @@ export function buildSelectionActions({
|
||||
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";
|
||||
|
||||
export function buildToolbarActions({
|
||||
@@ -6,9 +6,7 @@ export function buildToolbarActions({
|
||||
pagination,
|
||||
exportConfig,
|
||||
navigate,
|
||||
showArchived,
|
||||
hasAvailableCategories,
|
||||
onToggleArchived,
|
||||
getFilters,
|
||||
getSort,
|
||||
getTableInstance,
|
||||
@@ -21,11 +19,10 @@ export function buildToolbarActions({
|
||||
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => fetchPlans({
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters(),
|
||||
sort: getSort(),
|
||||
archived: showArchived,
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters(),
|
||||
sort: getSort(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -47,23 +44,31 @@ export function buildToolbarActions({
|
||||
variant: "outline",
|
||||
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",
|
||||
type: "button",
|
||||
label: "New Plan",
|
||||
icon: <Plus className="h-3.5 w-3.5" />,
|
||||
variant: "default",
|
||||
hidden: showArchived || !hasAvailableCategories,
|
||||
hidden: !hasAvailableCategories,
|
||||
onClick: () => navigate("/admin/tiers/plans/add"),
|
||||
},
|
||||
{
|
||||
key: "toggle-archived",
|
||||
key: "archived-plans",
|
||||
type: "button",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
label: showArchived ? "Active Plans" : "Archived Plans",
|
||||
label: "Archived Plans",
|
||||
variant: "secondary",
|
||||
className: "border border-border",
|
||||
onClick: onToggleArchived,
|
||||
onClick: () => navigate("/admin/tiers/plans/archived"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -50,8 +50,9 @@ const AdminLayout = () => {
|
||||
<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="flex gap-4 items-center">
|
||||
<div className="xs:hidden sm:block w-40 cursor-pointer" onClick={() => navigate(`/admin`)}>
|
||||
<img src="/philpro-white.png" alt="" className="object-cover" />
|
||||
<div className="w-40 cursor-pointer" onClick={() => navigate("/")}>
|
||||
<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>
|
||||
<svg
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function AddAdvertisement() {
|
||||
};
|
||||
|
||||
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="flex flex-col gap-2 my-6 w-full">
|
||||
<AppBreadcrumb items={breadcrumbItems} />
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function AdvertisementList() {
|
||||
}
|
||||
|
||||
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="flex flex-col gap-2 my-6 w-full">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -189,7 +189,7 @@ export default function EditAdvertisement() {
|
||||
};
|
||||
|
||||
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="flex flex-col gap-2 my-6 w-full">
|
||||
<AppBreadcrumb items={breadcrumbItems} />
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function ViewAdvertisement() {
|
||||
|
||||
if (loading && !advertisement) {
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<section className="bg-muted h-full">
|
||||
<div className="flex items-center justify-center py-32">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
@@ -74,7 +74,7 @@ export default function ViewAdvertisement() {
|
||||
|
||||
if (!advertisement) {
|
||||
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="flex flex-col gap-2 my-6 w-full">
|
||||
<AppBreadcrumb items={breadcrumbItems} />
|
||||
@@ -93,7 +93,7 @@ export default function ViewAdvertisement() {
|
||||
const ctas = Array.isArray(advertisement.ctas) ? advertisement.ctas : [];
|
||||
|
||||
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="flex flex-col gap-2 my-6 w-full">
|
||||
<AppBreadcrumb items={breadcrumbItems} />
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function ArchivedAssetList() {
|
||||
];
|
||||
|
||||
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="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function AssetList() {
|
||||
]
|
||||
|
||||
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="flex flex-col gap-2 my-6 ">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
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 { 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 { useAuth } from "@/contexts/AuthContext";
|
||||
import api from "@/utils/api.util";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
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 { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -22,6 +23,24 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -33,6 +52,7 @@ const schema = z.object({
|
||||
level: z.enum(["beginner", "intermediate", "advanced"]).optional(),
|
||||
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([]),
|
||||
achievement_keys: z.array(z.string()).max(3).default([]),
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -70,12 +90,18 @@ export default function AddCourse() {
|
||||
.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 {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -87,18 +113,35 @@ export default function AddCourse() {
|
||||
level: "beginner",
|
||||
subscription: "free",
|
||||
objectives: [],
|
||||
achievement_keys: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields: objectiveFields, append: appendObjective, remove: removeObjective } =
|
||||
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 payload = {
|
||||
...values,
|
||||
objectives: values.objectives.map((o) => o.text),
|
||||
level: values.level || 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,
|
||||
};
|
||||
|
||||
@@ -118,8 +161,8 @@ export default function AddCourse() {
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Course Details</h1>
|
||||
<p className="text-sm text-muted-foreground">View course information.</p>
|
||||
<h1 className="text-xl font-semibold">Add Course</h1>
|
||||
<p className="text-sm text-muted-foreground">Create a new training course.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -127,7 +170,6 @@ export default function AddCourse() {
|
||||
|
||||
{/* ── Basic Info ── */}
|
||||
<SectionCard title="Basic Information">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="title">Title <span className="text-destructive">*</span></Label>
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Settings ── */}
|
||||
<SectionCard title="Settings">
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Level</Label>
|
||||
<Select
|
||||
value={watch("level") ?? ""}
|
||||
value={watchedLevel ?? ""}
|
||||
onValueChange={(val) => setValue("level", val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -180,7 +219,7 @@ export default function AddCourse() {
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watch("subscription") ?? "free"}
|
||||
value={watchedSubscr ?? "free"}
|
||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -196,9 +235,7 @@ export default function AddCourse() {
|
||||
</Select>
|
||||
<FieldError message={errors.subscription?.message} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Objectives ── */}
|
||||
@@ -241,6 +278,195 @@ export default function AddCourse() {
|
||||
</div>
|
||||
</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 ── */}
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button
|
||||
@@ -260,6 +486,17 @@ export default function AddCourse() {
|
||||
</form>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function ArchivedCourseList() {
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<section className="bg-muted h-full">
|
||||
<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="flex flex-col gap-2 my-6">
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function CourseList() {
|
||||
];
|
||||
|
||||
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." />
|
||||
<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">
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
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 { 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 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 CourseInstructorPicker from "@/modules/admin/components/courses/CourseInstructorPicker";
|
||||
import { useCategories } from "@/contexts/AdminCategoriesContext";
|
||||
@@ -39,6 +43,7 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -56,21 +61,6 @@ const schema = z.object({
|
||||
|
||||
// ─── 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 }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
@@ -97,7 +87,7 @@ function SectionCard({ title, description, children }) {
|
||||
export default function EditCourse() {
|
||||
const navigate = useNavigate();
|
||||
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 { user } = useAuth();
|
||||
|
||||
@@ -120,6 +110,20 @@ export default function EditCourse() {
|
||||
const [instructorsDirty, setInstructorsDirty] = 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 ────────────────────────────────────────────────────────
|
||||
const [product, setProduct] = useState(null);
|
||||
const [productDirty, setProductDirty] = useState(false);
|
||||
@@ -134,7 +138,6 @@ export default function EditCourse() {
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -155,6 +158,12 @@ export default function EditCourse() {
|
||||
remove: removeObjective,
|
||||
} = 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 ────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -174,15 +183,33 @@ export default function EditCourse() {
|
||||
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 () => {
|
||||
await fetchCategories();
|
||||
const [cats, prod, insts] = await Promise.all([
|
||||
const [cats, prod, insts, achKeys] = await Promise.all([
|
||||
fetchCourseCategories(courseId),
|
||||
fetchCourseProduct(courseId),
|
||||
fetchInstructors(courseId),
|
||||
fetchCourseAchievements(courseId),
|
||||
]);
|
||||
setSelectedCategoryIds((cats ?? []).map((c) => String(c.id)));
|
||||
if (prod) {
|
||||
@@ -203,6 +230,7 @@ export default function EditCourse() {
|
||||
order_index: i.order_index ?? 0,
|
||||
}))
|
||||
);
|
||||
setSelectedAchievementKeys(achKeys ?? []);
|
||||
})();
|
||||
}, [courseId]);
|
||||
|
||||
@@ -281,6 +309,36 @@ export default function EditCourse() {
|
||||
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) => {
|
||||
if (!isDirty) return navigate(-1);
|
||||
|
||||
@@ -381,7 +439,7 @@ export default function EditCourse() {
|
||||
<div className="space-y-1.5">
|
||||
<Label>Level</Label>
|
||||
<Select
|
||||
value={watch("level") ?? ""}
|
||||
value={watchedLevel ?? ""}
|
||||
onValueChange={(val) =>
|
||||
setValue("level", val, { shouldDirty: true })
|
||||
}
|
||||
@@ -401,7 +459,7 @@ export default function EditCourse() {
|
||||
<div className="space-y-1.5">
|
||||
<Label>Subscription</Label>
|
||||
<Select
|
||||
value={watch("subscription") ?? "free"}
|
||||
value={watchedSubscription ?? "free"}
|
||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -701,40 +759,237 @@ export default function EditCourse() {
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── Certificate of Completion ── */}
|
||||
{/* ── Rewards ── */}
|
||||
<SectionCard
|
||||
title="Certificate of Completion"
|
||||
description="Automatically awarded to learners who pass this course's assessment. Mandatory for all courses."
|
||||
title="Rewards"
|
||||
description="Badge and achievements awarded to learners who complete this course."
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<CertBadgeIcon className="size-20 shrink-0" />
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">Certificate of Completion</span>
|
||||
<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">
|
||||
<BadgeCheck className="size-3" /> Mandatory
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
This badge is issued automatically when a learner passes the course assessment.
|
||||
It appears on their profile under <strong>Certificates</strong> and in the course
|
||||
content listing for all enrolled users.
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5 mt-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground">Label:</span> Certificate of Completion
|
||||
{/* ── 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>
|
||||
<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 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>
|
||||
|
||||
{/* 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>
|
||||
</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 ── */}
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
ArrowLeft, House, Pencil, Clock, BookOpen, Layers,
|
||||
ArrowLeft, Pencil, Clock, BookOpen, Layers,
|
||||
BadgeCheck, Tag, Star, Lock, ListChecks, BarChart2,
|
||||
Trophy, Users, Award,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useCourses } from "@/contexts/AdminCoursesContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -71,10 +74,43 @@ export default function ViewCourse() {
|
||||
const { fetchCourse, course, loading } = useCourses();
|
||||
const { fmtDateTime } = useDateFormat();
|
||||
|
||||
const [instructors, setInstructors] = useState([]);
|
||||
const [achievementKeys, setAchievementKeys] = useState([]);
|
||||
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
|
||||
// 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 (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title={course ? `${course.title} - STARR` : undefined} description={course?.description} />
|
||||
@@ -155,9 +191,7 @@ export default function ViewCourse() {
|
||||
<SectionCard icon={Clock} title="Duration & Stats">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<InfoRow label="Duration">
|
||||
{course.duration_formatted ?? course.duration_seconds
|
||||
? `${course.duration_seconds}s`
|
||||
: "—"}
|
||||
{course.duration_formatted ?? (course.duration_seconds ? `${course.duration_seconds}s` : "—")}
|
||||
</InfoRow>
|
||||
<InfoRow label="Units">
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
@@ -188,6 +222,102 @@ export default function ViewCourse() {
|
||||
</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 ── */}
|
||||
{course.prerequisites?.length > 0 && (
|
||||
<SectionCard icon={Star} title="Prerequisites">
|
||||
@@ -207,11 +337,11 @@ export default function ViewCourse() {
|
||||
<SectionCard icon={Lock} title="Final Assessment">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoRow label="Title">{course.assessment.title ?? "Untitled Assessment"}</InfoRow>
|
||||
<InfoRow label="Required">
|
||||
{/* <InfoRow label="Required">
|
||||
<Badge variant={course.assessment.is_required ? "default" : "secondary"}>
|
||||
{course.assessment.is_required ? "Required" : "Optional"}
|
||||
</Badge>
|
||||
</InfoRow>
|
||||
</InfoRow> */}
|
||||
<InfoRow label="Passing Score">{course.assessment.passing_score ?? 70}%</InfoRow>
|
||||
<InfoRow label="Time Limit">
|
||||
{course.assessment.time_limit_minutes
|
||||
@@ -247,4 +377,4 @@ export default function ViewCourse() {
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function AddLesson() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<section className="bg-muted h-full">
|
||||
<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">
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function ArchivedLessonsList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<section className="bg-muted h-full">
|
||||
<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="flex flex-col gap-2 my-6">
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function EditLesson() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<section className="bg-muted h-full">
|
||||
<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">
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ export default function LessonsList() {
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchCourse(courseId)
|
||||
if (!course || String(course.course_id) !== String(courseId)) {
|
||||
await fetchCourse(courseId);
|
||||
}
|
||||
await fetchUnit(courseId, unitId);
|
||||
setInitializing(false);
|
||||
})();
|
||||
@@ -42,7 +44,7 @@ export default function LessonsList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<section className="bg-muted h-full">
|
||||
<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="flex flex-col gap-2 my-6">
|
||||
|
||||
@@ -43,7 +43,7 @@ export default function ViewLesson() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<section className="bg-muted h-full">
|
||||
<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="w-full max-w-2xl space-y-6">
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function AddUnit() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<section className="bg-muted h-full">
|
||||
<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">
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function ArchivedUnitsList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<section className="bg-muted h-full">
|
||||
<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="flex flex-col gap-2 my-6">
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function EditUnit() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<section className="bg-muted h-full">
|
||||
<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">
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ export default function UnitsList() {
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
await fetchCourse(courseId);
|
||||
if (!course || String(course.course_id) !== String(courseId)) {
|
||||
await fetchCourse(courseId);
|
||||
}
|
||||
setInitializing(false);
|
||||
})();
|
||||
}, [courseId]);
|
||||
@@ -40,7 +42,7 @@ export default function UnitsList() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<section className="bg-muted h-full">
|
||||
<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="flex flex-col gap-2 my-6">
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function ViewUnit() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<section className="bg-muted h-full">
|
||||
<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">
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function ViewUnit() {
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<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" />
|
||||
</Button>
|
||||
<div>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function ArchiveTaskList() {
|
||||
];
|
||||
|
||||
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="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function TaskList() {
|
||||
];
|
||||
|
||||
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="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function ArchivedTask() {
|
||||
];
|
||||
|
||||
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="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -7,8 +7,9 @@ import { ArrowLeft, House } from "lucide-react";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
@@ -17,12 +18,38 @@ import api from "@/utils/api.util";
|
||||
|
||||
// ─── 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({
|
||||
tier_category_id: z.string().min(1, "Tier category 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."),
|
||||
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 }) {
|
||||
@@ -58,7 +85,7 @@ export default function AddPlan() {
|
||||
|
||||
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({
|
||||
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");
|
||||
@@ -154,17 +181,48 @@ export default function AddPlan() {
|
||||
<FieldError message={errors.label?.message} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="duration_days">Duration (days) <span className="text-destructive">*</span></Label>
|
||||
<Input id="duration_days" type="number" min={1} {...register("duration_days")} />
|
||||
<FieldError message={errors.duration_days?.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 className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Brief description shown to users on the plans page."
|
||||
rows={3}
|
||||
{...register("description")}
|
||||
/>
|
||||
<FieldError message={errors.description?.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>
|
||||
<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 className="space-y-1.5">
|
||||
@@ -176,9 +234,6 @@ export default function AddPlan() {
|
||||
|
||||
{categorySlug && (
|
||||
<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
|
||||
subscription={categorySlug}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,25 +3,61 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "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 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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
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 { CoursePicker } from "@/modules/admin/components/tiers/CoursePicker";
|
||||
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({
|
||||
label: z.string().min(1, "Label is required."),
|
||||
duration_days: z.coerce.number().min(1, "Duration must be at least 1 day."),
|
||||
price: z.coerce.number().min(0.01, "Price must be greater than 0."),
|
||||
currency: z.string().length(3),
|
||||
is_active: z.boolean().default(true),
|
||||
label: z.string().min(1, "Label is required."),
|
||||
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."),
|
||||
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 }) {
|
||||
@@ -44,8 +80,12 @@ export default function EditPlan() {
|
||||
const { planId } = useParams();
|
||||
const { fetchPlan, plan, updatePlan, loading } = useTiers();
|
||||
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||
const [coursesLoaded, setCoursesLoaded] = useState(false);
|
||||
const [selectedCourseIds, setSelectedCourseIds] = useState(new Set());
|
||||
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({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -57,12 +97,15 @@ export default function EditPlan() {
|
||||
|
||||
useEffect(() => {
|
||||
if (plan) {
|
||||
const unit = plan.duration_unit ?? "day";
|
||||
reset({
|
||||
label: plan.label,
|
||||
duration_days: plan.duration_days,
|
||||
price: plan.price,
|
||||
currency: plan.currency,
|
||||
is_active: plan.is_active,
|
||||
label: plan.label,
|
||||
description: plan.description ?? "",
|
||||
duration_value: durationDaysToValue(plan.duration_days, unit),
|
||||
duration_unit: unit,
|
||||
price: plan.price,
|
||||
currency: plan.currency,
|
||||
is_active: plan.is_active,
|
||||
});
|
||||
}
|
||||
}, [plan]);
|
||||
@@ -70,7 +113,7 @@ export default function EditPlan() {
|
||||
// Load existing assigned courses once the plan is known
|
||||
useEffect(() => {
|
||||
if (!planId || coursesLoaded) return;
|
||||
api.get(`/admin/tiers/plans/${planId}/courses`)
|
||||
api.get(`/admin/tiers/${planId}/courses`)
|
||||
.then(({ data }) => {
|
||||
const ids = (data.data ?? []).map((c) => String(c.course_id));
|
||||
setSelectedCourseIds(new Set(ids));
|
||||
@@ -79,18 +122,51 @@ export default function EditPlan() {
|
||||
.catch(() => setCoursesLoaded(true));
|
||||
}, [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);
|
||||
if (!result) return;
|
||||
|
||||
// Always sync (empty array clears all assignments)
|
||||
await api.post(`/admin/tiers/plans/${planId}/courses`, {
|
||||
course_ids: [...selectedCourseIds].map(Number),
|
||||
await api.post(`/admin/tiers/${planId}/courses`, {
|
||||
course_ids: [...selectedCourseIds],
|
||||
}).catch(() => {});
|
||||
|
||||
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 (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<PageMeta title={plan ? `Edit: ${plan.label} - STARR` : undefined} />
|
||||
@@ -129,17 +205,48 @@ export default function EditPlan() {
|
||||
<FieldError message={errors.label?.message} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="duration_days">Duration (days)</Label>
|
||||
<Input id="duration_days" type="number" min={1} {...register("duration_days")} />
|
||||
<FieldError message={errors.duration_days?.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 className="space-y-1.5">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Brief description shown to users on the plans page."
|
||||
rows={3}
|
||||
{...register("description")}
|
||||
/>
|
||||
<FieldError message={errors.description?.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>
|
||||
<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 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>
|
||||
<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>
|
||||
<Switch
|
||||
checked={watch("is_active") ?? true}
|
||||
@@ -162,21 +269,29 @@ export default function EditPlan() {
|
||||
|
||||
{plan?.tier && (
|
||||
<SectionCard title="Assigned Courses">
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Select which <span className="font-medium capitalize">{plan.tier}</span> courses are included in this plan.
|
||||
</p>
|
||||
<CoursePicker
|
||||
subscription={plan.tier}
|
||||
selectedIds={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
/>
|
||||
{coursesLoaded ? (
|
||||
<CoursePicker
|
||||
subscription={plan.tier}
|
||||
selectedIds={selectedCourseIds}
|
||||
onChange={setSelectedCourseIds}
|
||||
isPreloaded={true}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-1">
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading}>Cancel</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||
<Button type="button" variant="outline" onClick={() => navigate(-1)} disabled={loading || impactLoading}>Cancel</Button>
|
||||
<Button type="submit" disabled={loading || impactLoading}>
|
||||
{(loading || impactLoading) && <Spinner className="h-4 w-4 mr-2" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
@@ -184,6 +299,57 @@ export default function EditPlan() {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,29 +4,76 @@ import {
|
||||
ArrowLeft, House, ShieldCheck, ImagePlus, X, Check,
|
||||
Shield, Star, Trophy, Medal, Award, BadgeCheck, Gem,
|
||||
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";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import { TIER_COLOR_OPTIONS, getTierColor } from "@/utils/tierColors";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const BADGE_ICON_OPTIONS = [
|
||||
{ name: "ShieldCheck", icon: ShieldCheck },
|
||||
{ name: "Shield", icon: Shield },
|
||||
{ name: "BadgeCheck", icon: BadgeCheck },
|
||||
{ name: "Star", icon: Star },
|
||||
{ name: "Crown", icon: Crown },
|
||||
{ name: "Gem", icon: Gem },
|
||||
{ name: "Trophy", icon: Trophy },
|
||||
{ name: "Medal", icon: Medal },
|
||||
{ name: "Award", icon: Award },
|
||||
{ name: "Sparkles", icon: Sparkles },
|
||||
{ name: "Flame", icon: Flame },
|
||||
{ name: "Zap", icon: Zap },
|
||||
{ name: "Rocket", icon: Rocket },
|
||||
{ name: "Target", icon: Target },
|
||||
{ name: "Hexagon", icon: Hexagon },
|
||||
{ name: "Layers", icon: Layers },
|
||||
{ name: "CircleDot", icon: CircleDot },
|
||||
// Prestige / rank
|
||||
{ name: "ShieldCheck", icon: ShieldCheck },
|
||||
{ name: "Shield", icon: Shield },
|
||||
{ name: "BadgeCheck", icon: BadgeCheck },
|
||||
{ name: "BadgePlus", icon: BadgePlus },
|
||||
{ name: "BadgePercent", icon: BadgePercent },
|
||||
{ name: "Crown", icon: Crown },
|
||||
{ name: "Star", icon: Star },
|
||||
{ name: "Sparkles", icon: Sparkles },
|
||||
{ name: "Diamond", icon: Diamond },
|
||||
{ name: "Gem", icon: Gem },
|
||||
// Awards
|
||||
{ name: "Trophy", icon: Trophy },
|
||||
{ name: "Medal", icon: Medal },
|
||||
{ name: "Award", icon: Award },
|
||||
{ name: "Gift", icon: Gift },
|
||||
{ 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 { 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 (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<section className="bg-muted h-full">
|
||||
<PageMeta title="Payments - 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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
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 TierPlansTable from "../../components/tiers/TierPlansTable";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export default function PlanList() {
|
||||
const { fetchPlans } = useTiers();
|
||||
@@ -16,13 +17,63 @@ export default function PlanList() {
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<section className="bg-muted h-full">
|
||||
<PageMeta title="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">
|
||||
|
||||
<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 />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,7 +143,7 @@ function TierCategoriesInner() {
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Tier Category</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const { planId } = useParams();
|
||||
@@ -56,9 +74,17 @@ export default function ViewPlan() {
|
||||
const [tierCategories, setTierCategories] = useState([]);
|
||||
const tierMap = useMemo(() => Object.fromEntries(tierCategories.map((c) => [c.slug, c])), [tierCategories]);
|
||||
|
||||
const [assignedCourses, setAssignedCourses] = useState([]);
|
||||
const [coursesLoading, setCoursesLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlan(planId);
|
||||
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]);
|
||||
|
||||
return (
|
||||
@@ -111,7 +137,7 @@ export default function ViewPlan() {
|
||||
<InfoRow label="Tier">
|
||||
{(() => { const { cls, label } = resolveTierBadge(plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
|
||||
</InfoRow>
|
||||
<InfoRow label="Duration">{plan.duration_days} days</InfoRow>
|
||||
<InfoRow label="Duration">{formatDuration(plan.duration_days, plan.duration_unit)}</InfoRow>
|
||||
<InfoRow label="Price">
|
||||
{plan.currency} {Number(plan.price).toFixed(2)}
|
||||
</InfoRow>
|
||||
@@ -122,6 +148,61 @@ export default function ViewPlan() {
|
||||
</Badge>
|
||||
</InfoRow>
|
||||
</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 icon={BadgeCheck} title="Audit">
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function ArchivedGroupList() {
|
||||
]
|
||||
|
||||
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="flex flex-col gap-2 my-6 ">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function GroupList() {
|
||||
]
|
||||
|
||||
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="flex flex-col gap-2 my-6 ">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function ArchivedUserList() {
|
||||
]
|
||||
|
||||
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="flex flex-col gap-2 my-6 ">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function UserList() {
|
||||
]
|
||||
|
||||
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="flex flex-col gap-2 my-6 ">
|
||||
<AppBreadcrumb items={items} />
|
||||
|
||||
@@ -90,6 +90,9 @@ import UserTierList from '../pages/tiers/UserTierList';
|
||||
import PaymentList from '../pages/tiers/PaymentList';
|
||||
import ViewPayment from '../pages/tiers/ViewPayment';
|
||||
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 TaskSubmissions from '../pages/task_list/task/TaskCompletion'
|
||||
import ViewTaskCompletion from '../pages/task_list/task/ViewTaskCompletion'
|
||||
@@ -252,10 +255,14 @@ export const AdminRoutes = {
|
||||
children: [
|
||||
{ index: true, element: <PlanList /> },
|
||||
{ path: 'add', element: <AddPlan /> },
|
||||
{ path: ':planId/view', element: <ViewPlan /> },
|
||||
{ path: ':planId/edit', element: <EditPlan /> },
|
||||
{ path: 'archived', element: <ArchivedPlanList /> },
|
||||
{ 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: 'categories',
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
// components/QuizBlock.jsx
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
ChevronLeft, ChevronRight,
|
||||
Circle, CheckCircle2,
|
||||
Square, CheckSquare2,
|
||||
Clock, AlertTriangle, Info,
|
||||
Clock, AlertTriangle, Info, ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
|
||||
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() {
|
||||
return (
|
||||
<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
|
||||
* 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 questions = quiz?.questions ?? [];
|
||||
const total = questions.length;
|
||||
@@ -63,6 +76,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [reviewAttempted, setReviewAttempted] = useState(false);
|
||||
const [submitError, setSubmitError] = useState(null);
|
||||
|
||||
// ── Timer state ───────────────────────────────────────────────────────────
|
||||
const [remainingSeconds, setRemainingSeconds] = useState(null);
|
||||
@@ -216,11 +230,25 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
|
||||
};
|
||||
|
||||
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);
|
||||
const res = await onSubmit?.(answers, sessionRef.current.sessionId);
|
||||
setSubmitting(false);
|
||||
if (res) { setResult(res); setStage("result"); }
|
||||
}, [answers, onSubmit]);
|
||||
}, [answers, questions, onSubmit]);
|
||||
|
||||
const handleOptionClick = (optionId) => {
|
||||
const question = questions[currentIndex];
|
||||
@@ -239,8 +267,6 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
|
||||
|
||||
const handleGoToReview = () => {
|
||||
setReviewAttempted(true);
|
||||
const allAnswered = questions.every(q => isQuestionAnswered(answers[q.question_id]));
|
||||
if (!allAnswered) return;
|
||||
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">
|
||||
{hasTimeLimit && (
|
||||
<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>
|
||||
The timer starts the moment you begin and{" "}
|
||||
<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 className="flex items-start gap-2.5">
|
||||
<span className="mt-2 size-1.5 rounded-full bg-foreground shrink-0" />
|
||||
<span>Answer each question before moving to the next. You can return to any answered question to change your answer.</span>
|
||||
<span className="mt-2 size-1.5 rounded-full bg-primary shrink-0" />
|
||||
<span>You can skip questions and return to them later. All questions must be answered before you can submit.</span>
|
||||
</li>
|
||||
<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>
|
||||
Your progress is <strong>saved automatically</strong> — you can safely resume if you lose connection.
|
||||
</span>
|
||||
</li>
|
||||
<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>
|
||||
</li>
|
||||
</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>
|
||||
)}
|
||||
</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 && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" onClick={handleRetake}>Retake {label}</Button>
|
||||
@@ -559,7 +599,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
|
||||
{/* All questions grid */}
|
||||
<div className="rounded-xl border bg-card p-5 space-y-4">
|
||||
<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) => {
|
||||
const answered = isQuestionAnswered(answers[q.question_id]);
|
||||
return (
|
||||
@@ -569,7 +609,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
|
||||
onClick={() => { setCurrentIndex(i); setStage("taking"); }}
|
||||
className={`aspect-square rounded-lg text-sm font-medium transition-colors
|
||||
${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"
|
||||
}`}
|
||||
>
|
||||
@@ -580,7 +620,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
<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 className="flex items-center gap-1.5">
|
||||
<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 className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={() => setStage("taking")} disabled={submitting}>
|
||||
<ChevronLeft className="size-4" />
|
||||
Back to assessment
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Submitting…" : "Submit assessment"}
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={() => setStage("taking")} disabled={submitting}>
|
||||
<ChevronLeft className="size-4" />
|
||||
Back to assessment
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{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>
|
||||
);
|
||||
@@ -622,7 +669,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
|
||||
onClick={() => { setCurrentIndex(i); setStage("taking"); }}
|
||||
className={`size-9 rounded-full text-sm font-semibold transition-colors
|
||||
${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"
|
||||
}`}
|
||||
>
|
||||
@@ -670,7 +717,7 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
|
||||
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"
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -691,14 +738,21 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={() => setStage("taking")} disabled={submitting}>
|
||||
<ChevronLeft className="size-4" />
|
||||
Back to questions
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Submitting…" : "Submit quiz"}
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={() => setStage("taking")} disabled={submitting}>
|
||||
<ChevronLeft className="size-4" />
|
||||
Back to questions
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{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>
|
||||
);
|
||||
@@ -776,9 +830,100 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-5 items-start">
|
||||
{/* ── Left: question ── */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-4 items-start">
|
||||
{/* ── Right: sidebar — order-first on mobile so timer/progress sit above the question ── */}
|
||||
<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 & 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 */}
|
||||
<div className="flex items-center gap-2.5 text-sm">
|
||||
<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" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button onClick={handleNext} disabled={submitting || !isCurrentAnswered}>
|
||||
<Button onClick={handleNext} disabled={submitting}>
|
||||
{submitting ? "Submitting…" : isLast ? "Review & submit" : "Next"}
|
||||
{!submitting && <ChevronRight className="size-4" />}
|
||||
</Button>
|
||||
</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 & 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>
|
||||
);
|
||||
@@ -941,25 +996,24 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Question number pills */}
|
||||
{/* Question number pills — all freely clickable; skipped questions show as muted */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{questions.map((q, i) => {
|
||||
const ans = answers[q.question_id];
|
||||
const answered = isQuestionAnswered(ans);
|
||||
const isCurrent = i === currentIndex;
|
||||
const canJump = answered || isCurrent;
|
||||
const ans = answers[q.question_id];
|
||||
const answered = isQuestionAnswered(ans);
|
||||
const isCurrent = i === currentIndex;
|
||||
return (
|
||||
<button
|
||||
key={q.question_id}
|
||||
type="button"
|
||||
onClick={() => canJump && setCurrentIndex(i)}
|
||||
onClick={() => setCurrentIndex(i)}
|
||||
disabled={timeExpired && submitting}
|
||||
className={`size-9 rounded-full text-sm font-semibold transition-colors
|
||||
${isCurrent
|
||||
? "border-2 border-primary bg-background text-foreground"
|
||||
: answered
|
||||
? "bg-foreground text-background hover:opacity-80"
|
||||
: "bg-muted text-muted-foreground border border-border cursor-default"
|
||||
? "bg-primary text-background hover:opacity-80"
|
||||
: "bg-muted text-muted-foreground border border-border hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
{i + 1}
|
||||
@@ -1025,16 +1079,11 @@ const QuizBlock = ({ quiz, loading = false, onStart, onDraft, onRefreshSession,
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button onClick={handleNext} disabled={submitting || !isCurrentAnswered}>
|
||||
<Button onClick={handleNext} disabled={submitting}>
|
||||
{submitting ? "Submitting…" : isLast ? "Review answers" : "Next"}
|
||||
{!submitting && <ChevronRight className="size-4" />}
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -19,15 +19,18 @@ import {
|
||||
User, Settings, LogOut, SquareArrowOutUpRight, TableOfContents,
|
||||
CircleQuestionMark, Gift, Zap, Download, Copy, Check,
|
||||
} from "lucide-react"
|
||||
import * as LucideIcons from "lucide-react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Toaster } from "sonner"
|
||||
import { useAuth } from "@/contexts/AuthContext"
|
||||
import api from "@/utils/api.util"
|
||||
import { ClientProvider } from "@/contexts/provider/ClientProvider"
|
||||
import { useProfile } from "@/contexts/ProfileProvider"
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider"
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util"
|
||||
import { useGroup } from "@/contexts/ClientGroupContext"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { AVATAR_COLORS } from "@/data/profile.data"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import ClientNotificationBell from "@/components/generic/ClientNotificationBell"
|
||||
@@ -141,15 +144,19 @@ function ClientNav() {
|
||||
|
||||
// Background fetches only — nav rendering never waits on these
|
||||
const { achievements, getAchievements } = useProfile()
|
||||
const { myTier, getMyTier, getTierCategories } = useClientTiers()
|
||||
const { myTier, tierMap, tierCategories, getMyTier, getTierCategories } = useClientTiers()
|
||||
|
||||
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(() => {
|
||||
if (!user) return;
|
||||
if (achievements.length === 0) getAchievements();
|
||||
if (!myTier) getMyTier();
|
||||
getTierCategories();
|
||||
if (tierCategories.length === 0) getTierCategories();
|
||||
}, [user]);
|
||||
|
||||
// ── Derive directly from auth user — same pattern as admin UserMenu ──────
|
||||
@@ -161,14 +168,43 @@ function ClientNav() {
|
||||
const email = user?.email ?? ""
|
||||
const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
|
||||
|
||||
const TIER_NAV_BADGE = {
|
||||
free: { label: 'Free', className: '' },
|
||||
premium: { label: 'Premium Access', className: 'bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white border-0' },
|
||||
exclusive: { label: 'Exclusive Access', className: 'bg-gradient-to-r from-rose-500 to-red-600 text-white border-0' },
|
||||
}
|
||||
const tierBadge = myTier?.status === 'active'
|
||||
? (TIER_NAV_BADGE[myTier.tier] ?? TIER_NAV_BADGE.free)
|
||||
: TIER_NAV_BADGE.free
|
||||
const tierSlug = myTier?.status === 'active' ? (myTier.tier ?? 'free') : 'free'
|
||||
const tierBadge = resolveTierBadge(tierSlug, tierMap)
|
||||
const TierIcon = LucideIcons[tierMap[tierSlug]?.badge_icon] ?? null
|
||||
|
||||
// Derive primitive deps — effect only fires when the actual asset changes,
|
||||
// not on every tierMap/tierSlug reference churn.
|
||||
const badgeAsset = tierMap[tierSlug]?.badgeAsset ?? null
|
||||
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
|
||||
? (given[0] + last[0]).toUpperCase()
|
||||
@@ -211,9 +247,13 @@ function ClientNav() {
|
||||
</div>
|
||||
<div id="name" className="lg:-ml-1 font-medium text-sm flex items-center gap-2">
|
||||
{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}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -9,6 +9,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -19,10 +20,12 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useProfile } from "@/contexts/ProfileProvider";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { useCurrencyPreference } from "@/contexts/CurrencyPreferenceContext";
|
||||
import api from "@/utils/api.util";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -190,7 +193,7 @@ function SubscriptionSection() {
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<thead className="bg-muted/50 sticky top-0 z-10">
|
||||
<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">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>
|
||||
</tr>
|
||||
</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>
|
||||
<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>
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
function DeleteAccountSection({ logout }) {
|
||||
@@ -368,6 +446,10 @@ export default function AccountSettings() {
|
||||
<NewsletterSection />
|
||||
</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.">
|
||||
<DeleteAccountSection logout={logout} />
|
||||
</Section>
|
||||
|
||||
@@ -15,16 +15,22 @@ import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import {
|
||||
ArrowLeft, BookOpen, CalendarDays, Check,
|
||||
House, Loader2, ShieldCheck, Tag, Zap, LockIcon,
|
||||
Globe, House, Loader2, ShieldCheck, Tag, Zap, LockIcon,
|
||||
} from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
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 % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""}`;
|
||||
if (days % 30 === 0) return `${days / 30} month${days / 30 > 1 ? "s" : ""}`;
|
||||
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) {
|
||||
@@ -63,6 +69,8 @@ const Checkout = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { fmtCurrency } = useDateFormat();
|
||||
const { fmtPlanPrice, resolvePlanPrice } = useCurrency();
|
||||
const { currency, setCurrency } = useCurrencyPreference();
|
||||
|
||||
const planId = searchParams.get("plan_id");
|
||||
const returnToken = searchParams.get("token");
|
||||
@@ -74,24 +82,44 @@ const Checkout = () => {
|
||||
plans, plansLoading,
|
||||
myTier, tierLoading,
|
||||
checkoutLoading,
|
||||
promoLoading,
|
||||
getPlans, getMyTier,
|
||||
validatePromo,
|
||||
createOrder, captureOrder, cancelOrder,
|
||||
} = useClientTiers();
|
||||
|
||||
const [promoCode, setPromoCode] = useState("");
|
||||
const [isPromoApplied, setIsPromoApplied] = useState(false);
|
||||
const [capturing, setCapturing] = useState(false);
|
||||
const [promoCode, setPromoCode] = useState("");
|
||||
const [promoResult, setPromoResult] = useState(null); // { valid, code, type, value, discount }
|
||||
const [capturing, setCapturing] = useState(false);
|
||||
const capturingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
getMyTier();
|
||||
if (!plans.length) getPlans();
|
||||
}, [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(
|
||||
() => plans.find((p) => String(p.plan_id) === String(planId)) ?? null,
|
||||
[plans, planId]
|
||||
@@ -130,10 +158,11 @@ const Checkout = () => {
|
||||
const isCurrent = plan && myTier?.tier === plan.tier && myTier?.status === "active";
|
||||
const style = TIER_STYLES[plan?.tier] ?? TIER_STYLES.free;
|
||||
const Icon = style.icon;
|
||||
const subtotal = Number(plan?.price) || 0;
|
||||
const discount = isPromoApplied ? Math.min(10, subtotal) : 0;
|
||||
const { price: effectivePrice, currency: effectiveCurrency } = resolvePlanPrice(plan ?? {});
|
||||
const subtotal = plan ? effectivePrice : 0;
|
||||
const discount = promoResult?.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 = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/" },
|
||||
@@ -141,19 +170,29 @@ const Checkout = () => {
|
||||
{ label: "Checkout" },
|
||||
];
|
||||
|
||||
const handleApplyPromo = () => {
|
||||
if (promoCode.trim().toUpperCase() === "PHIL10") {
|
||||
setIsPromoApplied(true);
|
||||
const handleApplyPromo = async () => {
|
||||
if (!plan) return;
|
||||
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.");
|
||||
return;
|
||||
} else {
|
||||
toast.error(result?.reason ?? "Invalid promo code.");
|
||||
}
|
||||
toast.error("Invalid promo code.");
|
||||
};
|
||||
|
||||
const handleRemovePromo = () => {
|
||||
setPromoResult(null);
|
||||
setPromoCode("");
|
||||
};
|
||||
|
||||
const handlePayPal = async () => {
|
||||
const localeCurrency = effectiveCurrency !== plan.currency ? effectiveCurrency : null;
|
||||
const order = await createOrder(
|
||||
plan.plan_id,
|
||||
isPromoApplied ? promoCode.trim().toUpperCase() : null
|
||||
promoResult?.code ?? null,
|
||||
localeCurrency,
|
||||
);
|
||||
if (!order) return;
|
||||
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 (
|
||||
<div className="min-h-screen bg-muted pt-24">
|
||||
<PageMeta title={plan ? `Checkout – ${plan.label} - STARR` : undefined} />
|
||||
@@ -233,9 +297,25 @@ const Checkout = () => {
|
||||
{(plan.course_count ?? plan.courses?.length ?? 0) === 1 ? "" : "s"} included
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-primary">
|
||||
{fmtCurrency(plan.price, plan.currency)}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<p className="text-2xl font-bold text-primary">
|
||||
{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>
|
||||
|
||||
@@ -309,12 +389,12 @@ const Checkout = () => {
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between gap-4">
|
||||
<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>
|
||||
{isPromoApplied && (
|
||||
{promoResult?.valid && (
|
||||
<div className="flex justify-between gap-4 text-green-600">
|
||||
<span>Promo Discount (PHIL10)</span>
|
||||
<span>-{fmtCurrency(discount, plan.currency)}</span>
|
||||
<span>Promo ({promoResult.code})</span>
|
||||
<span>-{fmtCurrency(promoResult.discount, effectiveCurrency)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -323,29 +403,43 @@ const Checkout = () => {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="promo">Promo Code</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="promo"
|
||||
placeholder="PHIL10"
|
||||
value={promoCode}
|
||||
onChange={(e) => setPromoCode(e.target.value)}
|
||||
disabled={isPromoApplied || checkoutLoading}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleApplyPromo}
|
||||
disabled={isPromoApplied || checkoutLoading || !promoCode.trim()}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
{promoResult?.valid ? (
|
||||
<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">
|
||||
<Check className="size-4 shrink-0" />
|
||||
<span className="flex-1 font-medium">{promoResult.code}</span>
|
||||
<button
|
||||
onClick={handleRemovePromo}
|
||||
className="text-green-500 hover:text-green-700 text-xs underline"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="promo"
|
||||
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>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between items-center text-lg font-semibold">
|
||||
<span>Total</span>
|
||||
<span>{fmtCurrency(total, plan.currency)}</span>
|
||||
<span>{fmtCurrency(total, effectiveCurrency)}</span>
|
||||
</div>
|
||||
|
||||
{isCurrent ? (
|
||||
@@ -363,10 +457,9 @@ const Checkout = () => {
|
||||
? <Loader2 className="size-4 animate-spin" />
|
||||
: <ShieldCheck className="size-4" />
|
||||
}
|
||||
Pay {fmtCurrency(total, plan.currency)} with PayPal
|
||||
Pay {fmtCurrency(total, effectiveCurrency)} with PayPal
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground space-y-1">
|
||||
<p className="inline-flex items-center justify-center gap-1">
|
||||
<ShieldCheck className="size-4" />
|
||||
|
||||
@@ -4,8 +4,9 @@ import { useParams, useNavigate } from "react-router-dom";
|
||||
import api from "@/utils/api.util";
|
||||
import {
|
||||
House, Timer, Layers, GitBranch, Medal, CircleStar, LockIcon,
|
||||
SendHorizonal, CheckCheck, CheckCircle2, Clock,
|
||||
SendHorizonal, CheckCheck, CheckCircle2, Clock, FileQuestion, ClipboardList,
|
||||
} from "lucide-react";
|
||||
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -35,22 +36,6 @@ function formatDuration(seconds = 0) {
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -88,6 +73,7 @@ const useVisibleNodes = (refs, count) => {
|
||||
|
||||
const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle, isCompleted }) => {
|
||||
const navigate = useNavigate();
|
||||
const quiz = unit.quiz ?? null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -125,12 +111,17 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle,
|
||||
<Timer /> {formatDuration(unit.duration_seconds)}
|
||||
</div>
|
||||
)}
|
||||
{quiz && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileQuestion /> Quiz
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1.5 h-full">
|
||||
<div className="flex flex-col gap-1 pt-0">
|
||||
{(unit.lessons ?? []).map((lesson, li) => (
|
||||
{(unit.lessons ?? []).map((lesson) => (
|
||||
<div
|
||||
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"
|
||||
@@ -148,6 +139,28 @@ const UnitAccordionBlock = ({ unit, unitIndex, i, cardRefs, courseId, onToggle,
|
||||
)}
|
||||
</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>
|
||||
</AccordionContent>
|
||||
</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 ─────────────────────────────────────────────────────────
|
||||
|
||||
const CertCard = ({ courseTitle, courseLevel, pendingCert, certificate, delay, nodeRef }) => {
|
||||
const CertCard = ({ courseTitle, courseLevel, badgeColor, badgeImageUrl, pendingCert, certificate, delay, nodeRef }) => {
|
||||
const { fmtDate } = useDateFormat();
|
||||
const isIssued = !!certificate;
|
||||
const isIssued = !!certificate;
|
||||
const isPending = !isIssued && !!pendingCert;
|
||||
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -176,30 +247,49 @@ const CertCard = ({ courseTitle, courseLevel, pendingCert, certificate, delay, n
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, delay, ease: "easeOut" }}
|
||||
>
|
||||
<CertBadgeIcon className="w-24" />
|
||||
<p className="text-lg font-bold text-center leading-snug capitalize">{`${courseLevel} Level`}</p>
|
||||
<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-sm font-medium mt-1">{courseTitle}</p>
|
||||
</div>
|
||||
<CourseBadge
|
||||
title={courseTitle}
|
||||
level={courseLevel}
|
||||
color={badgeColor ?? "purple"}
|
||||
imageUrl={badgeImageUrl}
|
||||
/>
|
||||
|
||||
<div className="w-full flex items-end justify-between">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<DialogTrigger asChild>
|
||||
<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">
|
||||
<Clock className="size-3" /> Issued
|
||||
</Badge>
|
||||
</DialogTrigger>
|
||||
{isIssued ? (
|
||||
<DialogTrigger asChild>
|
||||
<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">
|
||||
<CheckCircle2 className="size-3" /> Issued
|
||||
</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>
|
||||
</motion.div>
|
||||
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<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"}
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
@@ -254,11 +344,18 @@ const CertCard = ({ courseTitle, courseLevel, pendingCert, certificate, delay, n
|
||||
|
||||
// ─── 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 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 [mids, setMids] = useState([]);
|
||||
const maxVisible = visibleNodes.size ? Math.max(...visibleNodes) : -1;
|
||||
@@ -278,7 +375,7 @@ const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, onToggle,
|
||||
const id = setTimeout(measure, 60);
|
||||
window.addEventListener("resize", measure);
|
||||
return () => { clearTimeout(id); window.removeEventListener("resize", measure); };
|
||||
}, [units.length]);
|
||||
}, [totalNodes]);
|
||||
|
||||
const lastMid = mids.length ? mids[mids.length - 1] : 0;
|
||||
const svgH = lastMid + 40;
|
||||
@@ -343,28 +440,53 @@ const CourseUnits = ({ units = [], courseId, courseTitle, courseLevel, onToggle,
|
||||
|
||||
{/* Cards */}
|
||||
<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) => (
|
||||
<UnitAccordionBlock
|
||||
key={unit.unit_id}
|
||||
unit={unit}
|
||||
unitIndex={i}
|
||||
i={i}
|
||||
cardRefs={cardRefs}
|
||||
courseId={courseId}
|
||||
onToggle={measure}
|
||||
isCompleted={isCompleted}
|
||||
/>
|
||||
))}
|
||||
{nodes.map((node, ni) => {
|
||||
const delay = ni * 0.05;
|
||||
const nodeRef = (el) => (cardRefs.current[ni] = el);
|
||||
|
||||
{/* Certificate badge — final node, always present */}
|
||||
<CertCard
|
||||
nodeRef={(el) => (cardRefs.current[units.length] = el)}
|
||||
delay={units.length * 0.05}
|
||||
courseTitle={courseTitle}
|
||||
courseLevel={courseLevel}
|
||||
pendingCert={pendingCert}
|
||||
certificate={certificate}
|
||||
/>
|
||||
if (node.type === "unit") {
|
||||
const unitIndex = units.indexOf(node.unit);
|
||||
return (
|
||||
<UnitAccordionBlock
|
||||
key={node.unit.unit_id}
|
||||
unit={node.unit}
|
||||
unitIndex={unitIndex}
|
||||
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>
|
||||
);
|
||||
@@ -383,6 +505,7 @@ const CourseDetails = () => {
|
||||
const { fetchCourseProgress, isCompleted, resetProgress } = useCourseReadingProgress();
|
||||
|
||||
const [tierMap, setTierMap] = useState({});
|
||||
const [badgeImageUrl, setBadgeImageUrl] = useState(null);
|
||||
useEffect(() => {
|
||||
api.get("/client/tiers/categories")
|
||||
.then(({ data }) => {
|
||||
@@ -390,7 +513,7 @@ const CourseDetails = () => {
|
||||
(data.data ?? []).forEach((c) => { m[c.slug] = c; });
|
||||
setTierMap(m);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => { });
|
||||
}, []);
|
||||
|
||||
const hasCompleted = !!course?.is_completed;
|
||||
@@ -403,9 +526,25 @@ const CourseDetails = () => {
|
||||
getMyTier();
|
||||
getCourse(courseId);
|
||||
fetchCourseProgress(courseId);
|
||||
return () => { resetCourse(); resetProgress(); };
|
||||
return () => { resetCourse(); resetProgress(); setBadgeImageUrl(null); };
|
||||
}, [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) {
|
||||
toast.error("You don't have access to this course. Upgrade your plan.");
|
||||
navigate("/course", { replace: true });
|
||||
@@ -535,9 +674,12 @@ const CourseDetails = () => {
|
||||
courseId={courseId}
|
||||
courseTitle={course.title}
|
||||
courseLevel={course.level}
|
||||
badgeColor={course.badge_color ?? "purple"}
|
||||
badgeImageUrl={badgeImageUrl}
|
||||
isCompleted={isCompleted}
|
||||
pendingCert={course.pending_certificate ?? null}
|
||||
certificate={course.certificate ?? null}
|
||||
assessment={course.assessment ?? null}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import api from "@/utils/api.util";
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util";
|
||||
import { Building2 } from "lucide-react";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -169,11 +170,16 @@ const CoursesList = () => {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedCourse, setSelectedCourse] = useState(null);
|
||||
|
||||
const [allCategories, setAllCategories] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
getCourses();
|
||||
api.get("/client/tiers/categories")
|
||||
.then(({ data }) => setTierCategories(data.data ?? []))
|
||||
.catch(() => {});
|
||||
api.get("/client/courses/categories")
|
||||
.then(({ data }) => setAllCategories(data.data ?? []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// slug → category info map
|
||||
@@ -183,13 +189,6 @@ const CoursesList = () => {
|
||||
return m;
|
||||
}, [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(() =>
|
||||
courses
|
||||
.filter((c) => {
|
||||
@@ -228,7 +227,7 @@ const CoursesList = () => {
|
||||
<div>
|
||||
<PageMeta title="Courses - STARR" description="Browse your available training courses." />
|
||||
<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} />
|
||||
|
||||
{/* Search & Filters */}
|
||||
@@ -298,10 +297,8 @@ const CoursesList = () => {
|
||||
</div>
|
||||
) : paginated.length === 0 ? (
|
||||
<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">
|
||||
<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" />
|
||||
</svg>
|
||||
<p className="text-sm">No courses found</p>
|
||||
<Building2 className="size-40 text-primary" />
|
||||
<p className="text-md">No courses found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xs:pt-12 lg:pt-0 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mb-4">
|
||||
|
||||
@@ -8,23 +8,9 @@ import { useProfile } from "@/contexts/ProfileProvider";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import api from "@/utils/api.util";
|
||||
import { toast } from "sonner";
|
||||
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
||||
|
||||
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-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 CertificateCard = ({ courseTitle, issuedAt, courseUuid, badgeColor, badgeImageUrl }) => {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
@@ -52,8 +38,11 @@ const CertificateCard = ({ courseTitle, issuedAt, courseUuid }) => {
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border bg-card p-6 flex flex-col items-center gap-4 shadow-sm">
|
||||
<CertBadgeIcon className="w-[160px] h-[160px]" />
|
||||
<p className="text-lg font-bold text-center leading-snug">Certificate of Completion</p>
|
||||
<CourseBadge
|
||||
title={courseTitle}
|
||||
color={badgeColor ?? "purple"}
|
||||
imageUrl={badgeImageUrl ?? null}
|
||||
/>
|
||||
<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-sm font-medium mt-1 line-clamp-2">{courseTitle}</p>
|
||||
@@ -79,10 +68,53 @@ export default function MyCertificates() {
|
||||
const navigate = useNavigate();
|
||||
const { achievements, achievementsLoading, getAchievements } = useProfile();
|
||||
|
||||
// { [courseUuid]: { badge_color, badge_image_url, badge_asset_id } }
|
||||
const [badgeDataMap, setBadgeDataMap] = useState({});
|
||||
|
||||
useEffect(() => { getAchievements(); }, []);
|
||||
|
||||
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 (
|
||||
<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">
|
||||
@@ -116,14 +148,20 @@ export default function MyCertificates() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{certAchievements.map((cert) => (
|
||||
<CertificateCard
|
||||
key={cert.achievement_id}
|
||||
courseTitle={cert.description}
|
||||
issuedAt={cert.granted_at}
|
||||
courseUuid={cert.metadata?.courseUuid ?? cert.key.replace("course_completed_", "")}
|
||||
/>
|
||||
))}
|
||||
{certAchievements.map((cert) => {
|
||||
const uuid = cert.metadata?.courseUuid ?? cert.key.replace("course_completed_", "");
|
||||
const bData = badgeDataMap[uuid] ?? {};
|
||||
return (
|
||||
<CertificateCard
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
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 {
|
||||
Megaphone, BookOpen, Clock, Check,
|
||||
Tag, LockIcon, Zap, RotateCcw,
|
||||
@@ -18,6 +22,7 @@ import { toast } from "sonner";
|
||||
import api from "@/utils/api.util";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { useCurrency } from "@/hooks/useCurrency";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -29,11 +34,13 @@ function formatCountdown(secs) {
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatDuration(days) {
|
||||
function formatDuration(days, unit) {
|
||||
if (!days) return null;
|
||||
if (days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""}`;
|
||||
if (days % 30 === 0) return `${days / 30} month${days / 30 > 1 ? "s" : ""}`;
|
||||
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) {
|
||||
@@ -48,25 +55,25 @@ function formatCourseDuration(seconds = 0) {
|
||||
// Badge styles per tier
|
||||
const TIER_STYLES = {
|
||||
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",
|
||||
icon: Tag,
|
||||
label: "Free",
|
||||
ring: "",
|
||||
icon: Tag,
|
||||
label: "Free",
|
||||
ring: "",
|
||||
},
|
||||
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",
|
||||
icon: Zap,
|
||||
label: "Premium",
|
||||
ring: "ring-2 ring-fuchsia-400/40",
|
||||
icon: Zap,
|
||||
label: "Premium",
|
||||
ring: "ring-2 ring-fuchsia-400/40",
|
||||
},
|
||||
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",
|
||||
icon: LockIcon,
|
||||
label: "Exclusive",
|
||||
ring: "ring-2 ring-rose-400/40",
|
||||
icon: LockIcon,
|
||||
label: "Exclusive",
|
||||
ring: "ring-2 ring-rose-400/40",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -94,104 +101,237 @@ const PlanSkeleton = () => (
|
||||
|
||||
// ─── Plan Card ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PREVIEW_COURSE_LIMIT = 2;
|
||||
|
||||
const PlanCard = ({ plan, myTier, onSelect, onView, onRefund, refundSecsLeft }) => {
|
||||
const { fmtCurrency } = useDateFormat();
|
||||
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
|
||||
const Icon = style.icon;
|
||||
const { fmtPlanPrice } = useCurrency();
|
||||
const [coursesOpen, setCoursesOpen] = useState(false);
|
||||
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 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 (
|
||||
<Card className={`relative flex flex-col ${style.ring}`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{plan.label}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCurrent && (
|
||||
<Badge className="bg-green-500 text-white">Current Plan</Badge>
|
||||
<>
|
||||
<Card className={`relative flex flex-col ${style.ring}`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{plan.label}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{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}>
|
||||
<Icon />
|
||||
{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>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
{plan.courses?.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
|
||||
<BookOpen className="size-3.5" />
|
||||
Course{plan.course_count !== 1 ? "s" : ""} Included
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{plan.courses.map((course) => (
|
||||
<li key={course.course_id} className="flex items-start gap-2 text-sm">
|
||||
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="line-clamp-1">{course.title}</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{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">
|
||||
<Clock className="size-3" />
|
||||
{formatCourseDuration(course.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
|
||||
{plan.courses?.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
|
||||
<BookOpen className="size-3.5" />
|
||||
Course{plan.course_count !== 1 ? "s" : ""} Included
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{previewCourses.map((course) => (
|
||||
<li key={course.course_id} className="flex items-start gap-2 text-sm">
|
||||
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="line-clamp-1">{course.title}</span>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{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">
|
||||
<Clock className="size-3" />
|
||||
{formatCourseDuration(course.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Access to all free course content.
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{extraCount > 0 && (
|
||||
<Badge
|
||||
type="button"
|
||||
// onClick={() => setCoursesOpen(true)}
|
||||
// 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>
|
||||
)}
|
||||
</CardContent>
|
||||
<DialogFooter>
|
||||
<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">
|
||||
<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>
|
||||
) : !isCurrent ? (
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant={style.button}
|
||||
onClick={() => onSelect(plan)}
|
||||
>
|
||||
{plan.tier === "free" ? "Current" : `Get ${style.label}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
{/* Price + Duration */}
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-2xl font-bold">
|
||||
{fmtPlanPrice(plan)}
|
||||
</span>
|
||||
{duration && (
|
||||
<span className="text-sm text-muted-foreground">/ {duration}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{plan.description && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed -mt-1">
|
||||
{plan.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Courses horizontal scroll */}
|
||||
{plan.courses?.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
|
||||
<BookOpen className="size-3.5" />
|
||||
{plan.courses.length} Course{plan.courses.length !== 1 ? "s" : ""} Included
|
||||
</p>
|
||||
<ScrollArea className="w-md whitespace-nowrap">
|
||||
<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() {
|
||||
const navigate = useNavigate();
|
||||
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 [refundSecsLeft, setRefundSecsLeft] = useState(0);
|
||||
const refundTimerRef = useRef(null);
|
||||
@@ -345,7 +486,7 @@ export default function PlanList() {
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Refund amount</span>
|
||||
<span className="font-medium">
|
||||
{refundPlan ? fmtCurrency(refundPlan.price, refundPlan.currency) : "—"}
|
||||
{refundPlan ? fmtPlanPrice(refundPlan) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
{myTier?.expires_at && (
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import api from "@/utils/api.util";
|
||||
import { toast } from "sonner";
|
||||
import CourseBadge from "@/modules/admin/components/courses/CourseBadge";
|
||||
|
||||
// ─── 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) ────────────────────────────
|
||||
|
||||
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-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 LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid, badgeColor, badgeImageUrl }) => {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const { fmtDate } = useDateFormat();
|
||||
|
||||
@@ -156,7 +142,7 @@ const LandscapeCertCard = ({ courseTitle, issuedAt, courseUuid }) => {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
@@ -195,6 +181,20 @@ const ProfilePage = () => {
|
||||
|
||||
const [badgeOpen, setBadgeOpen] = useState(false);
|
||||
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 [inProgressCoursesLoading, setInProgressCoursesLoading] = useState(false);
|
||||
@@ -222,7 +222,7 @@ const ProfilePage = () => {
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
|
||||
const tier = myTier?.tier ?? user?.tier ?? "free";
|
||||
const tierBadge = resolveTierBadge(myTier);
|
||||
const tierBadge = { ...resolveTierBadge(myTier), ...(badgeSrc ? { src: badgeSrc } : {}) };
|
||||
const earlyAccessBadge = resolveEarlyAccessBadge(systemBadges);
|
||||
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();
|
||||
@@ -232,6 +232,34 @@ const ProfilePage = () => {
|
||||
|
||||
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");
|
||||
// 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;
|
||||
@@ -612,6 +640,8 @@ const ProfilePage = () => {
|
||||
courseTitle={certAchievements[0].description}
|
||||
issuedAt={certAchievements[0].granted_at}
|
||||
courseUuid={certAchievements[0].metadata?.courseUuid ?? certAchievements[0].key.replace("course_completed_", "")}
|
||||
badgeColor={firstCertBadge.color}
|
||||
badgeImageUrl={firstCertBadge.imageUrl}
|
||||
/>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
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 { ButtonGroup } from "@/components/ui/button-group";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -196,7 +195,7 @@ const SidebarContent = ({
|
||||
? <CheckCircle2 className="size-4 text-emerald-500 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>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-1">
|
||||
@@ -216,7 +215,7 @@ const SidebarContent = ({
|
||||
? <CheckCircle2 className="size-3.5 text-emerald-500 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>
|
||||
);
|
||||
})}
|
||||
@@ -241,7 +240,7 @@ const SidebarContent = ({
|
||||
? <Lock className="size-3.5 text-amber-500 shrink-0" />
|
||||
: <ClipboardList className="size-3.5 shrink-0" />
|
||||
}
|
||||
{unit.quiz.title || "Quiz"}
|
||||
{"Quiz"}
|
||||
</li>
|
||||
);
|
||||
})()}
|
||||
@@ -264,7 +263,7 @@ const SidebarContent = ({
|
||||
? <Lock className="size-4 shrink-0 text-amber-500" />
|
||||
: <GraduationCap className="size-4 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
{courseAssessment.title || "Course Assessment"}
|
||||
Assessment
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -690,6 +689,12 @@ const UnitList = () => {
|
||||
|
||||
const units = course?.units ?? [];
|
||||
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 = () => {
|
||||
if (!nextContent) return;
|
||||
@@ -757,7 +762,7 @@ const UnitList = () => {
|
||||
|
||||
{/* ── Task-mode banner ─────────────────────────────────────────── */}
|
||||
{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'
|
||||
}`}>
|
||||
<ListChecks className="size-3.5 shrink-0" />
|
||||
@@ -788,6 +793,24 @@ const UnitList = () => {
|
||||
</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 ── */}
|
||||
<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">
|
||||
@@ -835,17 +858,6 @@ const UnitList = () => {
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</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>
|
||||
|
||||
{/* Scroll progress bar */}
|
||||
@@ -859,31 +871,52 @@ const UnitList = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Desktop sidebar ── */}
|
||||
{desktopSidebarOpen && (
|
||||
<div className={`hidden lg:block fixed ${taskCtx?.has_task ? "top-[148px]" : "top-[124px]"} bottom-0 left-0 w-80 bg-muted border-r`}>
|
||||
<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)}
|
||||
loading={courseLoading}
|
||||
/>
|
||||
{/* ── Desktop sidebar: rail + collapsible content panel (desktop only) ── */}
|
||||
<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`}>
|
||||
{/* Rail — always visible */}
|
||||
<div className="w-14 shrink-0 flex flex-col items-center pt-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
onClick={() => setDesktopSidebarOpen((prev) => !prev)}
|
||||
disabled={quizSessionActive}
|
||||
title={showSidebarContent ? "Collapse sidebar" : "Expand sidebar"}
|
||||
>
|
||||
{showSidebarContent
|
||||
? <ChevronsLeft className="size-4" />
|
||||
: <ChevronsRight className="size-4" />
|
||||
}
|
||||
</Button>
|
||||
</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 ── */}
|
||||
<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">
|
||||
{selectedCompletion ? (
|
||||
<CourseCompleteBlock course={course} />
|
||||
@@ -909,6 +942,8 @@ const UnitList = () => {
|
||||
}}
|
||||
onRetake={() => getCourseAssessment(courseId)}
|
||||
onActiveChange={setQuizActive}
|
||||
onNextContent={nextContent ? handleNextContentClick : undefined}
|
||||
nextLabel={nextLabel}
|
||||
/>
|
||||
)
|
||||
) : selectedQuizId ? (
|
||||
@@ -930,6 +965,8 @@ const UnitList = () => {
|
||||
}}
|
||||
onRetake={() => getUnitQuiz(courseId, selectedUnitId)}
|
||||
onActiveChange={setQuizActive}
|
||||
onNextContent={nextContent ? handleNextContentClick : undefined}
|
||||
nextLabel={nextLabel}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Card, CardContent, CardHeader, CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
ArrowLeft, BookOpen, Clock, Check,
|
||||
Tag, LockIcon, Zap, CalendarDays,
|
||||
Star, Users, Trophy, Shield, Flame,
|
||||
} from "lucide-react";
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider";
|
||||
import { useProfile } from "@/contexts/ProfileProvider";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { useCurrency } from "@/hooks/useCurrency";
|
||||
import { useCurrencyPreference } from "@/contexts/CurrencyPreferenceContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
|
||||
// ─── 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 % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""}`;
|
||||
if (days % 30 === 0) return `${days / 30} month${days / 30 > 1 ? "s" : ""}`;
|
||||
return `${days} days`;
|
||||
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) {
|
||||
@@ -29,31 +32,60 @@ function formatCourseDuration(seconds = 0) {
|
||||
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`;
|
||||
if (h) return `${h}h`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
// ─── Tier config ──────────────────────────────────────────────────────────────
|
||||
|
||||
const TIER_STYLES = {
|
||||
free: {
|
||||
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",
|
||||
icon: Tag,
|
||||
label: "Free",
|
||||
button: "default",
|
||||
badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white",
|
||||
heroBg: "from-lime-500 via-green-600 to-emerald-700",
|
||||
accentColor: "text-lime-600 dark:text-lime-400",
|
||||
accentBg: "bg-lime-50 dark:bg-lime-950/30",
|
||||
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: {
|
||||
badge: "bg-gradient-to-r from-fuchsia-600 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",
|
||||
icon: Zap,
|
||||
label: "Premium",
|
||||
button: "default",
|
||||
badge: "bg-gradient-to-r from-fuchsia-500 to-purple-600 text-white",
|
||||
heroBg: "from-fuchsia-600 via-purple-700 to-violet-800",
|
||||
accentColor: "text-fuchsia-600 dark:text-fuchsia-400",
|
||||
accentBg: "bg-fuchsia-50 dark:bg-fuchsia-950/30",
|
||||
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: {
|
||||
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",
|
||||
icon: LockIcon,
|
||||
label: "Exclusive",
|
||||
button: "default",
|
||||
badge: "bg-gradient-to-r from-rose-500 to-red-600 text-white",
|
||||
heroBg: "from-rose-600 via-red-700 to-orange-800",
|
||||
accentColor: "text-rose-600 dark:text-rose-400",
|
||||
accentBg: "bg-rose-50 dark:bg-rose-950/30",
|
||||
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 = () => (
|
||||
<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-8 w-32" />
|
||||
<Skeleton className="h-40 w-full rounded-2xl" />
|
||||
<Skeleton className="h-72 w-full" />
|
||||
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-4 mt-4">
|
||||
<Skeleton className="h-28 w-full rounded-2xl" />
|
||||
<Skeleton className="h-64 w-full rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,173 +107,222 @@ const ViewPlan = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { myTier, getMyTier, plans, plansLoading, getPlans } = useClientTiers();
|
||||
const { fmtCurrency } = useDateFormat();
|
||||
const { profile, getProfile } = useProfile();
|
||||
const { fmtPlanPrice } = useCurrency();
|
||||
const { currency, setCurrency } = useCurrencyPreference();
|
||||
|
||||
useEffect(() => {
|
||||
getProfile();
|
||||
getMyTier();
|
||||
if (!plans.length) getPlans();
|
||||
}, [id]);
|
||||
|
||||
const plan = plans.find((p) => String(p.plan_id) === String(id)) ?? null;
|
||||
const loading = plansLoading;
|
||||
// Seed currency preference from the user's stored profile
|
||||
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 (!plan) return null;
|
||||
|
||||
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
|
||||
const Icon = style.icon;
|
||||
const duration = formatDuration(plan.duration_days);
|
||||
const style = TIER_STYLES[plan.tier] ?? TIER_STYLES.free;
|
||||
const Icon = style.icon;
|
||||
const duration = formatDuration(plan.duration_days, plan.duration_unit);
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="mt-17 bg-muted min-h-screen">
|
||||
<PageMeta title={plan ? `${plan.label} - STARR` : undefined} />
|
||||
<div className="p-6 lg:container lg:max-w-3xl lg:mx-auto space-y-4">
|
||||
|
||||
{/* Back */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate("/plans")}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">Plan Details</h1>
|
||||
<p className="text-xs text-muted-foreground">Review what's included before subscribing</p>
|
||||
{/* ── Hero Banner ───────────────────────────────────────────── */}
|
||||
<div className={`relative bg-gradient-to-br ${style.heroBg} overflow-hidden`}>
|
||||
{/* Decorative blobs */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute -top-24 -right-24 w-96 h-96 rounded-full bg-white/5" />
|
||||
<div className="absolute -bottom-16 -left-16 w-72 h-72 rounded-full bg-white/5" />
|
||||
<div className="absolute top-1/2 left-1/3 w-48 h-48 rounded-full bg-white/5" />
|
||||
</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>
|
||||
|
||||
{/* Plan banner */}
|
||||
<Card className={`bg-gradient-to-r ${style.banner} border`}>
|
||||
<CardContent className="py-6 px-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<Badge className={style.badge}>
|
||||
<Icon className="size-3.5" /> {style.label}
|
||||
</Badge>
|
||||
<h2 className="text-2xl font-bold">{plan.label}</h2>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-3xl font-bold">
|
||||
{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>
|
||||
)}
|
||||
{/* ── Stats row ─────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: "Courses", value: plan.course_count ?? 0, icon: BookOpen },
|
||||
{ label: "Content", value: totalDuration ?? "—", icon: Clock },
|
||||
{ label: "Access", value: duration ?? "Lifetime", icon: CalendarDays },
|
||||
].map(({ label, value, icon: StatIcon }) => (
|
||||
<div key={label} className="rounded-xl bg-card border p-4 flex flex-col items-center gap-1 text-center">
|
||||
<StatIcon className={`size-4 ${style.accentColor}`} />
|
||||
<p className="text-2xl font-bold leading-none mt-1">{value}</p>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
</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>
|
||||
|
||||
{/* Included courses */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<BookOpen className="size-4" />
|
||||
Included Courses
|
||||
<Badge variant="secondary">{plan.course_count ?? 0}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* ── Included Courses ──────────────────────────────────── */}
|
||||
<div className="rounded-2xl bg-card border overflow-hidden">
|
||||
<div className="px-5 py-4 border-b flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className={`size-4 ${style.accentColor}`} />
|
||||
<span className="text-sm font-semibold">Included Courses</span>
|
||||
</div>
|
||||
<Badge variant="secondary">{plan.course_count ?? 0}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{plan.courses?.length > 0 ? (
|
||||
plan.courses.map((course, i) => (
|
||||
<div key={course.course_id}>
|
||||
{i > 0 && <Separator className="mb-3" />}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center shrink-0">
|
||||
<BookOpen className="size-4 text-secondary-foreground" />
|
||||
plan.courses.map((course) => (
|
||||
<div key={course.course_id} className="flex items-start gap-4 px-5 py-4">
|
||||
<div className={`h-10 w-10 rounded-xl ${style.accentBg} border ${style.accentBorder} flex items-center justify-center shrink-0`}>
|
||||
<BookOpen className={`size-5 ${style.accentColor}`} />
|
||||
</div>
|
||||
<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 className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium line-clamp-2">{course.title}</p>
|
||||
<div className="flex items-center gap-3 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>
|
||||
<Check className="size-4 text-green-500 shrink-0 mt-0.5" />
|
||||
</div>
|
||||
<div className="h-6 w-6 rounded-full bg-green-100 dark:bg-green-950 flex items-center justify-center shrink-0">
|
||||
<Check className="size-3.5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground/50">
|
||||
<BookOpen className="size-8 mb-2" />
|
||||
<p className="text-sm">No courses assigned to this plan yet.</p>
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center px-6">
|
||||
<div className={`h-14 w-14 rounded-2xl ${style.accentBg} border ${style.accentBorder} flex items-center justify-center mb-3`}>
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom CTA */}
|
||||
{/* ── Bottom CTA ────────────────────────────────────────── */}
|
||||
{!isCurrent && (
|
||||
<div className="flex justify-end gap-2 pb-6">
|
||||
<Button variant="outline" onClick={() => navigate("/plans")}>
|
||||
Back to Plans
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
onClick={() => navigate(`/plans/checkout?plan_id=${plan.plan_id}`)}
|
||||
>
|
||||
Get {style.label} Plan — {fmtCurrency(plan.price, plan.currency)}
|
||||
</Button>
|
||||
<div className={`rounded-2xl bg-gradient-to-br ${style.heroBg} p-6 text-center relative overflow-hidden`}>
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 rounded-full bg-white/5" />
|
||||
<div className="absolute -bottom-8 -left-8 w-32 h-32 rounded-full bg-white/5" />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<p className="text-white font-bold text-lg mb-1">
|
||||
{plan.is_active ? "Ready to get started?" : "Coming Soon"}
|
||||
</p>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -250,4 +331,4 @@ const ViewPlan = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewPlan;
|
||||
export default ViewPlan;
|
||||
|
||||
@@ -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." },
|
||||
];
|
||||
@@ -79,6 +79,116 @@ export const TIER_COLOR_MAP = {
|
||||
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" },
|
||||
},
|
||||
|
||||
// ── 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. */
|
||||
|
||||
Reference in New Issue
Block a user