diff --git a/LICENSE b/LICENSE index 261eeb9..c4662f9 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2025 [Kenneth Obsequio and Russell Obsequio] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 1df1e69..92dc71f 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ import { PageMeta } from '@/contexts/MetadataContext' export default function CourseList() { return ( -
+
+
{/* rest of page */}
diff --git a/src/App.jsx b/src/App.jsx index 2a5639f..ed05867 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 + return } @@ -80,11 +84,13 @@ export default function App() { - - - - - + + + + + + + diff --git a/src/components/AppLoadingScreen.jsx b/src/components/AppLoadingScreen.jsx new file mode 100644 index 0000000..4e4f67c --- /dev/null +++ b/src/components/AppLoadingScreen.jsx @@ -0,0 +1,12 @@ +import { Spinner } from './ui/spinner' + +const APP_NAME = import.meta.env.VITE_APP_NAME ?? 'STARR' + +export default function AppLoadingScreen() { + return ( +
+ +

Loading {APP_NAME}...

+
+ ) +} diff --git a/src/components/generic/AssetPickerSheet.jsx b/src/components/generic/AssetPickerSheet.jsx index b12f994..4daf3ed 100644 --- a/src/components/generic/AssetPickerSheet.jsx +++ b/src/components/generic/AssetPickerSheet.jsx @@ -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); }; diff --git a/src/components/generic/GroupMultiSelect.jsx b/src/components/generic/GroupMultiSelect.jsx index 1469b8b..d35a970 100644 --- a/src/components/generic/GroupMultiSelect.jsx +++ b/src/components/generic/GroupMultiSelect.jsx @@ -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 = () => { diff --git a/src/components/ui/date-time-picker.jsx b/src/components/ui/date-time-picker.jsx new file mode 100644 index 0000000..2b90b47 --- /dev/null +++ b/src/components/ui/date-time-picker.jsx @@ -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 ( + + + + + + + date < new Date(new Date().setHours(0, 0, 0, 0))} + autoFocus + /> + +
+ + + + {value && ( + + )} +
+
+
+ ); +} diff --git a/src/components/ui/progress.jsx b/src/components/ui/progress.jsx index ca6bc6d..0b3a879 100644 --- a/src/components/ui/progress.jsx +++ b/src/components/ui/progress.jsx @@ -20,7 +20,7 @@ function Progress({ {...props}> = 100 ? "bg-green-500" : "bg-primary")} style={{ transform: `translateX(-${100 - (value || 0)}%)` }} /> ); diff --git a/src/contexts/AdminCoursesContext.jsx b/src/contexts/AdminCoursesContext.jsx index 5acb00d..b5a1301 100644 --- a/src/contexts/AdminCoursesContext.jsx +++ b/src/contexts/AdminCoursesContext.jsx @@ -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} diff --git a/src/contexts/ClientTiersProvider.jsx b/src/contexts/ClientTiersProvider.jsx index 3ec7a0e..8a3bf48 100644 --- a/src/contexts/ClientTiersProvider.jsx +++ b/src/contexts/ClientTiersProvider.jsx @@ -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, diff --git a/src/contexts/CurrencyPreferenceContext.jsx b/src/contexts/CurrencyPreferenceContext.jsx new file mode 100644 index 0000000..fd2e679 --- /dev/null +++ b/src/contexts/CurrencyPreferenceContext.jsx @@ -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 ( + + {children} + + ); +} + +export function useCurrencyPreference() { + const ctx = useContext(CurrencyPreferenceContext); + if (!ctx) throw new Error('useCurrencyPreference must be used inside CurrencyPreferenceProvider'); + return ctx; +} diff --git a/src/hooks/useCurrency.js b/src/hooks/useCurrency.js new file mode 100644 index 0000000..fe456c7 --- /dev/null +++ b/src/hooks/useCurrency.js @@ -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, + }; +} diff --git a/src/modules/admin/components/courses/CourseBadge.jsx b/src/modules/admin/components/courses/CourseBadge.jsx new file mode 100644 index 0000000..43aa8f8 --- /dev/null +++ b/src/modules/admin/components/courses/CourseBadge.jsx @@ -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 ( +
+ {imageUrl ? ( + <> +
+
+ + ) : ( +
+ )} + {/* HIDE OUR LOGO */} + {/*
+ Philpro +
*/} +
+ ); + } + + return ( +
+ + {/* ── Layer 1: Background — image when set, solid gradient otherwise ── */} + {imageUrl ? ( +
+ ) : ( +
+ )} + + {/* ── Layer 2: Gradient colour overlay on top of the image ── */} + {imageUrl && ( +
+ )} + + {/* ── Layer 3: Soft glow in the upper-right corner (always present) ── */} +
+ + {/* ── Layer 4: Content ── */} +
+
+

+ {title} +

+ {levelLabel && ( +

+ {levelLabel} +

+ )} +
+ +
+
+
+ Philpro + + Course
Completion +
+
+
+
+
+ ); +} diff --git a/src/modules/admin/components/courses/CourseReadingProgressList.jsx b/src/modules/admin/components/courses/CourseReadingProgressList.jsx index b17ecdc..5db96d0 100644 --- a/src/modules/admin/components/courses/CourseReadingProgressList.jsx +++ b/src/modules/admin/components/courses/CourseReadingProgressList.jsx @@ -78,7 +78,7 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) { return ( - +
{entry && } @@ -110,7 +110,7 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) { {/* ── Unit / lesson breakdown — fills remaining height and scrolls ── */} - + {detailLoading && !breakdown ? (
{[...Array(4)].map((_, i) => ( diff --git a/src/modules/admin/components/tiers/ArchivedTierPlansTable.jsx b/src/modules/admin/components/tiers/ArchivedTierPlansTable.jsx new file mode 100644 index 0000000..ef3e69a --- /dev/null +++ b/src/modules/admin/components/tiers/ArchivedTierPlansTable.jsx @@ -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 ( + <> + []} + onRefsReady={handleRefsReady} + renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => ( + + )} + columnPinning={columnPinning} + toolbarActions={toolbarActions} + selectionActions={selectionActions} + recordLabel="plan" + emptyMessage="No archived plans found." + /> + + !v && setRestoreTarget(null)} + entity={restoreTarget} + entityLabel="Plan" + getName={(r) => r?.label} + onRestore={(entity) => restorePlan(entity?.plan_id)} + loading={loading} + onSuccess={handleRestoreSuccess} + /> + + !v && setRestoreIds(null)} + ids={restoreIds ?? []} + entityLabel="Plan" + onRestore={({ ids }) => bulkRestorePlans(ids)} + loading={loading} + onSuccess={handleRestoreSuccess} + /> + + ); +} diff --git a/src/modules/admin/components/tiers/CoursePicker.jsx b/src/modules/admin/components/tiers/CoursePicker.jsx index c552653..8d2cce3 100644 --- a/src/modules/admin/components/tiers/CoursePicker.jsx +++ b/src/modules/admin/components/tiers/CoursePicker.jsx @@ -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 of selected course_id strings + * subscription — tier slug ("premium"). Null/undefined = hidden. + * selectedIds — Set of selected course_id strings (managed by parent) * onChange — (Set) => 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 ( -
-
-

- {loading - ? "Loading courses…" - : `${courses.length} course${courses.length !== 1 ? "s" : ""} in this tier${selectedCount > 0 ? ` — ${selectedCount} selected` : ""}` - } -

-
- - -
-
+
+ {/* ── Bundle question ──────────────────────────────────────────── */} {loading ? ( -
- {[...Array(3)].map((_, i) => )} -
- ) : courses.length === 0 ? ( -
- - No courses found with subscription "{subscription}". +
+ +
) : ( - - - - {filtered.length === 0 ? ( - No courses match your search. - ) : ( - - {filtered.map((course) => { - const id = String(course.course_id); - const checked = selectedIds.has(id); - return ( - toggle(id)} - className="flex items-start gap-3 px-3 py-2.5 cursor-pointer" - > - toggle(id)} - className="mt-0.5 shrink-0" - onClick={(e) => e.stopPropagation()} - /> -
- {course.title} - {course.description && ( - - {course.description} - - )} -
-
- ); - })} -
- )} -
-
+
+

+ Bundle {subscription} courses with this plan? +

+
+ + +
+
+ )} + + {/* ── Bundle all summary ───────────────────────────────────────── */} + {!loading && bundleAll && total > 0 && ( +

+ All {total} {subscription} course{total !== 1 ? "s" : ""} will be included. +

+ )} + + {/* ── No courses in tier ───────────────────────────────────────── */} + {!loading && total === 0 && ( +
+ + No {subscription} courses found. Add courses with this subscription first. +
+ )} + + {/* ── Specific picker (Popover) ─────────────────────────────────── */} + {!loading && !bundleAll && total > 0 && ( +
+ + + + + + + + + + {filtered.length === 0 ? ( + No courses match your search. + ) : ( + + {filtered.map((course) => { + const id = String(course.course_id); + const checked = selectedIds.has(id); + return ( + toggle(id)} + className="flex items-start gap-3 px-3 py-2.5 cursor-pointer" + > + toggle(id)} + className="mt-0.5 shrink-0" + onClick={(e) => e.stopPropagation()} + /> +
+ {course.title} + {course.description && ( + + {course.description} + + )} +
+
+ ); + })} +
+ )} +
+ + {/* Popover footer */} +
+ + {selectedCount} of {total} selected + +
+ 0 ? "indeterminate" : false} + onCheckedChange={(v) => v ? checkAll() : resetAll()} + /> + + {selectedCount === total ? "Deselect all" : "Select all"} + +
+
+
+
+
+ + {selectedCount === 0 && ( +

+ + Select at least one course to bundle with this plan. +

+ )} +
)}
); diff --git a/src/modules/admin/components/tiers/TierPlansTable.jsx b/src/modules/admin/components/tiers/TierPlansTable.jsx index ebd52ce..074cb84 100644 --- a/src/modules/admin/components/tiers/TierPlansTable.jsx +++ b/src/modules/admin/components/tiers/TierPlansTable.jsx @@ -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 */} - !v && setRestoreTarget(null)} - entity={restoreTarget} - entityLabel="Plan" - getName={(r) => r?.label} - onRestore={(entity) => restorePlan(entity?.plan_id)} - loading={loading} - onSuccess={handleSuccess} - /> - {/* Bulk archive */} - {/* Bulk restore */} - !v && setRestoreIds(null)} - ids={restoreIds ?? []} - entityLabel="Plan" - onRestore={({ ids }) => bulkRestorePlans(ids)} - loading={loading} - onSuccess={handleSuccess} - /> ); } \ No newline at end of file diff --git a/src/modules/admin/config/tiers/plans/archive/columns.config.jsx b/src/modules/admin/config/tiers/plans/archive/columns.config.jsx new file mode 100644 index 0000000..13c63a8 --- /dev/null +++ b/src/modules/admin/config/tiers/plans/archive/columns.config.jsx @@ -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 ( +
+ + + {count} {count === 1 ? "unit" : "units"} + +
+ ); + }, + lessonCount: (info) => { + const count = parseInt(info.getValue() ?? 0, 10); + return ( +
+ + + {count} {count === 1 ? "lesson" : "lessons"} + +
+ ); + }, + duration_seconds: (info) => { + const seconds = parseInt(info.getValue() ?? 0, 10); + return ( +
+ + + {formatDuration(seconds)} + +
+ ); + }, +}; + +export function buildDataColumns(attributes, rowActions) { + const visibleAttributes = attributes.filter((a) => !a.hidden); + return [ + buildSelectionColumn(), + ...buildColumns(visibleAttributes, { cellOverrides }), + buildRowActionsColumn(rowActions, { dropdownLabel: "Plan Actions" }), + ]; +} diff --git a/src/modules/admin/config/tiers/plans/archive/rowActions.config.jsx b/src/modules/admin/config/tiers/plans/archive/rowActions.config.jsx new file mode 100644 index 0000000..cee6fed --- /dev/null +++ b/src/modules/admin/config/tiers/plans/archive/rowActions.config.jsx @@ -0,0 +1,20 @@ +import { Eye, RotateCcw } from "lucide-react"; + +export function buildRowActions({ navigate, onRestore }) { + return [ + { + key: "view", + label: "View Plan", + icon: , + onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/view`), + }, + { + key: "restore", + label: "Restore", + icon: , + className: "text-emerald-600", + onClick: (row) => onRestore(row), + separator: true, + }, + ]; +} diff --git a/src/modules/admin/config/tiers/plans/archive/selection.config.jsx b/src/modules/admin/config/tiers/plans/archive/selection.config.jsx new file mode 100644 index 0000000..e759710 --- /dev/null +++ b/src/modules/admin/config/tiers/plans/archive/selection.config.jsx @@ -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: , + onClick: (rows, table) => + exportTableToExcel({ + ...exportConfig, + selectedRows: rows, + tableInstance: table ?? getTableInstance(), + }), + }, + { + key: "restore-selected", + label: "Restore", + icon: , + 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); + }, + }, + ]; +} diff --git a/src/modules/admin/config/tiers/plans/archive/toolbar.config.jsx b/src/modules/admin/config/tiers/plans/archive/toolbar.config.jsx new file mode 100644 index 0000000..c1527cf --- /dev/null +++ b/src/modules/admin/config/tiers/plans/archive/toolbar.config.jsx @@ -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: , + variant: "outline", + onClick: () => fetchPlans({ + page: 1, + limit: pagination?.limit ?? 10, + filters: getFilters(), + sort: getSort(), + archived: true, + }), + }, + { + key: "export", + type: "button", + label: "Export", + icon: , + variant: "outline", + onClick: () => exportTableToExcel({ + ...exportConfig, + tableInstance: getTableInstance(), + }), + }, + { + key: "active-plans", + type: "button", + label: "Active Plans", + icon: , + variant: "secondary", + className: "border border-border", + onClick: () => navigate("/admin/tiers/plans"), + }, + ]; +} diff --git a/src/modules/admin/config/tiers/plans/columns.config.jsx b/src/modules/admin/config/tiers/plans/columns.config.jsx index d7b8368..89b3ec9 100644 --- a/src/modules/admin/config/tiers/plans/columns.config.jsx +++ b/src/modules/admin/config/tiers/plans/columns.config.jsx @@ -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 = {
); }, + duration_days: (info) => { + const days = info.getValue(); + const unit = info.row.original.duration_unit; + return ( +
+ + {fmtPlanDuration(days, unit)} +
+ ); + }, duration_seconds: (info) => { const seconds = parseInt(info.getValue() ?? 0, 10); diff --git a/src/modules/admin/config/tiers/plans/rowActions.config.jsx b/src/modules/admin/config/tiers/plans/rowActions.config.jsx index 99ada9b..d6e793c 100644 --- a/src/modules/admin/config/tiers/plans/rowActions.config.jsx +++ b/src/modules/admin/config/tiers/plans/rowActions.config.jsx @@ -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: , onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/edit`), - hidden: () => showArchived, }, - { - key: "view_units", - label: "View Payments", - icon: , + { + key: "payment_policy", + label: "Payment Policy", + icon: , + 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: , 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: , - className: "text-emerald-600", - onClick: (row) => onRestore(row), - hidden: () => !showArchived, }, ]; } \ No newline at end of file diff --git a/src/modules/admin/config/tiers/plans/selection.config.jsx b/src/modules/admin/config/tiers/plans/selection.config.jsx index 8e76bd2..75ba94e 100644 --- a/src/modules/admin/config/tiers/plans/selection.config.jsx +++ b/src/modules/admin/config/tiers/plans/selection.config.jsx @@ -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: , @@ -31,15 +29,5 @@ export function buildSelectionActions({ ids.length === 1 ? onArchive(rows[0]) : onArchiveMany(ids); }, }, - showArchived && { - key: "restore-selected", - label: "Restore", - icon: , - 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); + ]; } \ No newline at end of file diff --git a/src/modules/admin/config/tiers/plans/toolbar.config.jsx b/src/modules/admin/config/tiers/plans/toolbar.config.jsx index 3e52976..0d3aa98 100644 --- a/src/modules/admin/config/tiers/plans/toolbar.config.jsx +++ b/src/modules/admin/config/tiers/plans/toolbar.config.jsx @@ -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: , 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: , + variant: "outline", + onClick: () => navigate("/admin/tiers/prices"), + }, { key: "create", type: "button", label: "New Plan", icon: , variant: "default", - hidden: showArchived || !hasAvailableCategories, + hidden: !hasAvailableCategories, onClick: () => navigate("/admin/tiers/plans/add"), }, { - key: "toggle-archived", + key: "archived-plans", type: "button", icon: , - label: showArchived ? "Active Plans" : "Archived Plans", + label: "Archived Plans", variant: "secondary", className: "border border-border", - onClick: onToggleArchived, + onClick: () => navigate("/admin/tiers/plans/archived"), }, ]; } \ No newline at end of file diff --git a/src/modules/admin/layouts/AdminLayout.jsx b/src/modules/admin/layouts/AdminLayout.jsx index 0541b56..8fad68d 100644 --- a/src/modules/admin/layouts/AdminLayout.jsx +++ b/src/modules/admin/layouts/AdminLayout.jsx @@ -50,8 +50,9 @@ const AdminLayout = () => {
-
navigate(`/admin`)}> - +
navigate("/")}> + Philproperties + Philproperties
+
diff --git a/src/modules/admin/pages/advertisements/AdvertisementList.jsx b/src/modules/admin/pages/advertisements/AdvertisementList.jsx index bf4d357..9d64799 100644 --- a/src/modules/admin/pages/advertisements/AdvertisementList.jsx +++ b/src/modules/admin/pages/advertisements/AdvertisementList.jsx @@ -51,7 +51,7 @@ export default function AdvertisementList() { } return ( -
+
diff --git a/src/modules/admin/pages/advertisements/EditAdvertisement.jsx b/src/modules/admin/pages/advertisements/EditAdvertisement.jsx index 2f799e6..40e5771 100644 --- a/src/modules/admin/pages/advertisements/EditAdvertisement.jsx +++ b/src/modules/admin/pages/advertisements/EditAdvertisement.jsx @@ -189,7 +189,7 @@ export default function EditAdvertisement() { }; return ( -
+
diff --git a/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx b/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx index 5efd56f..9af85db 100644 --- a/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx +++ b/src/modules/admin/pages/advertisements/ViewAdvertisement.jsx @@ -64,7 +64,7 @@ export default function ViewAdvertisement() { if (loading && !advertisement) { return ( -
+
@@ -74,7 +74,7 @@ export default function ViewAdvertisement() { if (!advertisement) { return ( -
+
@@ -93,7 +93,7 @@ export default function ViewAdvertisement() { const ctas = Array.isArray(advertisement.ctas) ? advertisement.ctas : []; return ( -
+
diff --git a/src/modules/admin/pages/assets/ArchivedAssets.jsx b/src/modules/admin/pages/assets/ArchivedAssets.jsx index 8c1d441..6c33e3c 100644 --- a/src/modules/admin/pages/assets/ArchivedAssets.jsx +++ b/src/modules/admin/pages/assets/ArchivedAssets.jsx @@ -11,7 +11,7 @@ export default function ArchivedAssetList() { ]; return ( -
+
diff --git a/src/modules/admin/pages/assets/AssetList.jsx b/src/modules/admin/pages/assets/AssetList.jsx index 1918674..2f5a928 100644 --- a/src/modules/admin/pages/assets/AssetList.jsx +++ b/src/modules/admin/pages/assets/AssetList.jsx @@ -10,7 +10,7 @@ export default function AssetList() { ] return ( -
+
diff --git a/src/modules/admin/pages/courses/AddCourse.jsx b/src/modules/admin/pages/courses/AddCourse.jsx index 92fb1aa..0fa3dba 100644 --- a/src/modules/admin/pages/courses/AddCourse.jsx +++ b/src/modules/admin/pages/courses/AddCourse.jsx @@ -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() {
-

Course Details

-

View course information.

+

Add Course

+

Create a new training course.

@@ -127,7 +170,6 @@ export default function AddCourse() { {/* ── Basic Info ── */} -
@@ -151,18 +193,15 @@ export default function AddCourse() {
- {/* ── Settings ── */} -
-
setValue("subscription", val, { shouldDirty: true })} > @@ -196,9 +235,7 @@ export default function AddCourse() {
-
-
{/* ── Objectives ── */} @@ -241,6 +278,195 @@ export default function AddCourse() {
+ {/* ── Rewards ── */} + + {/* ── Completion Badge ── */} +
+

Completion Badge

+
+ +
+ {/* Metadata */} +
+
+ Label: Course Completion +
+
+ Trigger: Pass course assessment +
+
+ Type: Milestone achievement +
+ + Mandatory + +
+ + {/* Color picker */} +
+

+ Color +

+
+ {TIER_COLOR_OPTIONS.map((opt) => ( +
+
+ + {/* Image picker */} +
+

+ Image (optional) +

+
+ {badgeImageUrl && ( +
+ +
+ )} + + {badgeImageUrl && ( + + )} +
+
+
+
+
+ + {/* ── Achievements ── */} +
+
+

Achievements

+ {currentAchKeys.length}/3 selected +
+ + {/* Selected badges */} + {currentAchKeys.length > 0 && ( +
+ {currentAchKeys.map((key) => { + const ach = ACHIEVEMENT_REGISTRY.find((a) => a.key === key); + return ( + + {ach?.label ?? key} + + + ); + })} +
+ )} + + {/* Popover picker */} + + + + + + + + + No achievements found. + + + {ACHIEVEMENT_REGISTRY.map((ach) => { + const checked = currentAchKeys.includes(ach.key); + const disabled = !checked && currentAchKeys.length >= 3; + return ( + !disabled && toggleAchievement(ach.key)} + className="gap-2 items-start py-2" + > + +
+
+ {ach.label} + + {ach.type === "badge" + ? + : + } + {ach.type} + +
+

{ach.description}

+
+ {checked && } +
+ ); + })} +
+
+
+
+
+
+
+
+ {/* ── Actions ── */}
+ + {/* Asset picker for badge image */} + { + setBadgeImageUrl(resolvedUrl ?? null); + setBadgeAssetId(asset.asset_id); + }} + />
); -} \ No newline at end of file +} diff --git a/src/modules/admin/pages/courses/ArchivedCourseList.jsx b/src/modules/admin/pages/courses/ArchivedCourseList.jsx index cf7a186..abf4740 100644 --- a/src/modules/admin/pages/courses/ArchivedCourseList.jsx +++ b/src/modules/admin/pages/courses/ArchivedCourseList.jsx @@ -11,7 +11,7 @@ export default function ArchivedCourseList() { ]; return ( -
+
diff --git a/src/modules/admin/pages/courses/CourseList.jsx b/src/modules/admin/pages/courses/CourseList.jsx index 2e3d9c1..90973cd 100644 --- a/src/modules/admin/pages/courses/CourseList.jsx +++ b/src/modules/admin/pages/courses/CourseList.jsx @@ -10,7 +10,7 @@ export default function CourseList() { ]; return ( -
+
diff --git a/src/modules/admin/pages/courses/EditCourse.jsx b/src/modules/admin/pages/courses/EditCourse.jsx index 070c740..27604b9 100644 --- a/src/modules/admin/pages/courses/EditCourse.jsx +++ b/src/modules/admin/pages/courses/EditCourse.jsx @@ -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 }) => ( - - - - - - - - - - - - -); - function FieldError({ message }) { if (!message) return null; return

{message}

; @@ -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() {
setValue("subscription", val, { shouldDirty: true })} > @@ -701,40 +759,237 @@ export default function EditCourse() {
- {/* ── Certificate of Completion ── */} + {/* ── Rewards ── */} -
- -
-
- Certificate of Completion - - Mandatory - -
-

- This badge is issued automatically when a learner passes the course assessment. - It appears on their profile under Certificates and in the course - content listing for all enrolled users. -

-
-
- Label: Certificate of Completion + {/* ── Completion Badge ── */} +
+

Completion Badge

+
+ +
+ {/* Metadata */} +
+
+ Label: Course Completion +
+
+ Trigger: Pass course assessment +
+
+ Type: Milestone achievement +
+ + Mandatory +
-
- Trigger: Pass course assessment + + {/* Color picker */} +
+

+ Color +

+
+ {TIER_COLOR_OPTIONS.map((opt) => ( +
-
- Type: Milestone achievement + + {/* Image picker */} +
+

+ Image (optional) +

+
+ {badgeImageUrl && ( +
+ +
+ )} + + {badgeImageUrl && ( + + )} +
+ + {/* Badge save */} +
+ +
+
+ + {/* ── Achievements ── */} +
+
+

Achievements

+ {selectedAchievementKeys.length}/3 selected +
+ + {/* Selected badges */} + {selectedAchievementKeys.length > 0 && ( +
+ {selectedAchievementKeys.map((key) => { + const ach = ACHIEVEMENT_REGISTRY.find((a) => a.key === key); + return ( + + {ach?.label ?? key} + + + ); + })} +
+ )} + + {/* Popover picker */} + + + + + + + + + No achievements found. + + + {ACHIEVEMENT_REGISTRY.map((ach) => { + const checked = selectedAchievementKeys.includes(ach.key); + const disabled = !checked && selectedAchievementKeys.length >= 3; + return ( + !disabled && toggleAchievement(ach.key)} + className="gap-2 items-start py-2" + > + +
+
+ {ach.label} + + {ach.type === "badge" + ? + : + } + {ach.type} + +
+

{ach.description}

+
+ {checked && } +
+ ); + })} +
+
+
+
+
+
+ +
+ +
+ {/* Asset picker for badge image */} + { + // 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 ── */}
diff --git a/src/modules/admin/pages/task_list/ArchiveTaskList.jsx b/src/modules/admin/pages/task_list/ArchiveTaskList.jsx index 94a1afd..db3ed06 100644 --- a/src/modules/admin/pages/task_list/ArchiveTaskList.jsx +++ b/src/modules/admin/pages/task_list/ArchiveTaskList.jsx @@ -10,7 +10,7 @@ export default function ArchiveTaskList() { ]; return ( -
+
diff --git a/src/modules/admin/pages/task_list/TaskList.jsx b/src/modules/admin/pages/task_list/TaskList.jsx index dd08e1f..e8835aa 100644 --- a/src/modules/admin/pages/task_list/TaskList.jsx +++ b/src/modules/admin/pages/task_list/TaskList.jsx @@ -9,7 +9,7 @@ export default function TaskList() { ]; return ( -
+
diff --git a/src/modules/admin/pages/task_list/task/ArchiveTask.jsx b/src/modules/admin/pages/task_list/task/ArchiveTask.jsx index 5b01144..f725dd1 100644 --- a/src/modules/admin/pages/task_list/task/ArchiveTask.jsx +++ b/src/modules/admin/pages/task_list/task/ArchiveTask.jsx @@ -13,7 +13,7 @@ export default function ArchivedTask() { ]; return ( -
+
diff --git a/src/modules/admin/pages/tiers/AddPlan.jsx b/src/modules/admin/pages/tiers/AddPlan.jsx index eb0a222..f08195b 100644 --- a/src/modules/admin/pages/tiers/AddPlan.jsx +++ b/src/modules/admin/pages/tiers/AddPlan.jsx @@ -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() {
-
-
- - - -
-
- - - +
+ +