Signed-off-by: rgrgogu <obsequio.rus@gmail.com>
This commit is contained in:
rgrgogu
2026-08-13 20:00:06 +08:00
parent 377a4d4b5e
commit 3c5432a32b
29 changed files with 104 additions and 1029 deletions
@@ -23,7 +23,7 @@ function loadImage(src) {
}) })
} }
async function cropToBlob(imageSrc, pixels, outputSize = 512) { async function cropToBlob(imageSrc, pixels, outputSize = 200) {
const img = await loadImage(imageSrc) const img = await loadImage(imageSrc)
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = outputSize canvas.width = outputSize
+3 -3
View File
@@ -11,8 +11,8 @@ export default function DashboardGrid({ sections = [] }) {
} }
return ( return (
<section className="bg-muted/60"> <section className="">
<div className="lg:container lg:mx-auto flex flex-col items-start gap-8 p-4"> <div className="lg:container lg:mx-auto flex flex-col items-start gap-8 p-6">
{sections.map(({ title, description, tiles }) => ( {sections.map(({ title, description, tiles }) => (
<div key={title} className="flex flex-col items-start gap-4 w-full"> <div key={title} className="flex flex-col items-start gap-4 w-full">
@@ -25,7 +25,7 @@ export default function DashboardGrid({ sections = [] }) {
</div> </div>
{/* Tiles */} {/* Tiles */}
<div className="grid xs:grid-cols-2 lg:grid-cols-5 h-fit w-full gap-4"> <div className="grid xs:grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 h-fit w-full gap-4">
{tiles.map(({ key, label, icon: Icon, link }) => ( {tiles.map(({ key, label, icon: Icon, link }) => (
<motion.div <motion.div
key={key} key={key}
+36 -30
View File
@@ -3,7 +3,8 @@
* Type : Reusable Component * Type : Reusable Component
* Description : Combined date + time picker for deadline fields. * Description : Combined date + time picker for deadline fields.
* Controlled via a single ISO datetime string (value / onChange). * Controlled via a single ISO datetime string (value / onChange).
* Date is picked via a Calendar popover; time via a plain time input. * Calendar + time input live inside a Card, matching shadcn's
* "Date and Time Picker" pattern.
* *
* Props: * Props:
* value : string | null — ISO datetime string e.g. "2026-06-01T10:30" * value : string | null — ISO datetime string e.g. "2026-06-01T10:30"
@@ -12,12 +13,13 @@
***********************************************************************************************************************************************************************/ ***********************************************************************************************************************************************************************/
import { useState } from 'react'; import { useState } from 'react';
import { format, parseISO, isValid } from 'date-fns'; import { format, parseISO, isValid } from 'date-fns';
import { ChevronDownIcon } from 'lucide-react'; import { ChevronDownIcon, Clock2Icon } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar'; import { Calendar } from '@/components/ui/calendar';
import { Input } from '@/components/ui/input'; import { Card, CardContent, CardFooter } from '@/components/ui/card';
import { Label } from '@/components/ui/label'; import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -51,7 +53,6 @@ export default function DeadlinePicker({ value, onChange, disabled = false }) {
const handleDateSelect = (date) => { const handleDateSelect = (date) => {
onChange(buildISO(date, timeStr)); onChange(buildISO(date, timeStr));
setOpen(false);
}; };
const handleTimeChange = (e) => { const handleTimeChange = (e) => {
@@ -61,62 +62,67 @@ export default function DeadlinePicker({ value, onChange, disabled = false }) {
const handleClear = () => onChange(null); const handleClear = () => onChange(null);
return ( return (
<div className="flex flex-wrap items-end gap-3">
{/* ── Date picker ──────────────────────────────────────────────── */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Date</Label>
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
disabled={disabled} disabled={disabled}
className="w-36 justify-between font-normal text-sm" className="w-56 justify-between font-normal text-sm"
> >
{selectedDate ? format(selectedDate, 'MMM d, yyyy') : 'Select date'} {selectedDate
? `${format(selectedDate, 'MMM d, yyyy')} ${format(parseISO(buildISO(selectedDate, timeStr)), 'h:mm a')}`
: 'Select date'}
<ChevronDownIcon className="h-4 w-4 opacity-50" /> <ChevronDownIcon className="h-4 w-4 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="start"> <PopoverContent className="w-auto border-0 bg-transparent p-0 shadow-none ring-0" align="start">
<Card size="sm" className="w-fit">
<CardContent>
<Calendar <Calendar
mode="single" mode="single"
selected={selectedDate} selected={selectedDate}
captionLayout="dropdown" captionLayout="dropdown"
defaultMonth={selectedDate} defaultMonth={selectedDate}
onSelect={handleDateSelect} onSelect={handleDateSelect}
disabled={disabled}
className="p-0"
/> />
</PopoverContent> </CardContent>
</Popover> <CardFooter className="flex-col items-stretch gap-3 border-t bg-card">
</div> <FieldGroup>
<Field orientation="horizontal">
{/* ── Time input ───────────────────────────────────────────────── */} <FieldLabel htmlFor="deadline-time">Time</FieldLabel>
<div className="space-y-2"> <InputGroup>
<Label className="text-xs text-muted-foreground">Time</Label> <InputGroupInput
<Input id="deadline-time"
type="time" type="time"
step="60"
value={timeStr} value={timeStr}
onChange={handleTimeChange} onChange={handleTimeChange}
disabled={disabled || !selectedDate} disabled={disabled || !selectedDate}
step="60" className="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
className="w-28 appearance-none bg-background
[&::-webkit-calendar-picker-indicator]:hidden
[&::-webkit-calendar-picker-indicator]:appearance-none"
/> />
</div> <InputGroupAddon>
<Clock2Icon className="text-muted-foreground" />
{/* ── Clear ────────────────────────────────────────────────────── */} </InputGroupAddon>
</InputGroup>
</Field>
</FieldGroup>
{value && ( {value && (
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm"
disabled={disabled} disabled={disabled}
onClick={handleClear} onClick={handleClear}
> >
Clear Clear
</Button> </Button>
)} )}
</CardFooter>
</div> </Card>
</PopoverContent>
</Popover>
); );
} }
+2 -1
View File
@@ -3,6 +3,7 @@ import { useProfile } from '@/contexts/ProfileProvider'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Camera, Loader2, Phone, MapPin, Shield, Trophy, Activity, Plus, Trash2, Pencil, Home, Building2, Edit2, Check, X } from 'lucide-react' import { Camera, Loader2, Phone, MapPin, Shield, Trophy, Activity, Plus, Trash2, Pencil, Home, Building2, Edit2, Check, X } from 'lucide-react'
import AvatarUploadDialog from '@/components/generic/AvatarUploadDialog' import AvatarUploadDialog from '@/components/generic/AvatarUploadDialog'
import { resolveAssetSrc } from '@/utils/media.util'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -233,7 +234,7 @@ export default function ProfilePage() {
const fullName = info?.name?.full_name ?? user?.email ?? 'User' const fullName = info?.name?.full_name ?? user?.email ?? 'User'
const initials = ((info?.name?.given_name?.[0] ?? '') + (info?.name?.last_name?.[0] ?? '')).toUpperCase() || user?.email?.[0]?.toUpperCase() || 'U' const initials = ((info?.name?.given_name?.[0] ?? '') + (info?.name?.last_name?.[0] ?? '')).toUpperCase() || user?.email?.[0]?.toUpperCase() || 'U'
const avatarUrl = info?.avatar?.url ?? null const avatarUrl = resolveAssetSrc(info?.avatar)
const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground' const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
// Load fresh profile data on mount // Load fresh profile data on mount
-50
View File
@@ -448,54 +448,6 @@ export function LibraryProvider({ children }) {
[request], [request],
); );
// ─── Product listings (individual purchase, parity with course products) ──
const fetchUnitProduct = useCallback(
(unitId) => request(async () => {
const { data } = await api.get(`/admin/products/units/${unitId}/product`);
return data.data ?? null;
}), [request],
);
const saveUnitProduct = useCallback(
(unitId, payload) => request(async () => {
const { data } = await api.put(`/admin/products/units/${unitId}/product`, payload);
toast("Product listing saved.");
return data.data ?? null;
}), [request],
);
const removeUnitProduct = useCallback(
(unitId) => request(async () => {
await api.delete(`/admin/products/units/${unitId}/product`);
toast("Product listing removed.");
return true;
}), [request],
);
const fetchLessonProduct = useCallback(
(lessonId) => request(async () => {
const { data } = await api.get(`/admin/products/lessons/${lessonId}/product`);
return data.data ?? null;
}), [request],
);
const saveLessonProduct = useCallback(
(lessonId, payload) => request(async () => {
const { data } = await api.put(`/admin/products/lessons/${lessonId}/product`, payload);
toast("Product listing saved.");
return data.data ?? null;
}), [request],
);
const removeLessonProduct = useCallback(
(lessonId) => request(async () => {
await api.delete(`/admin/products/lessons/${lessonId}/product`);
toast("Product listing removed.");
return true;
}), [request],
);
// ─── Value ──────────────────────────────────────────────────────────────── // ─── Value ────────────────────────────────────────────────────────────────
const value = { const value = {
// shared table state // shared table state
@@ -511,7 +463,6 @@ export function LibraryProvider({ children }) {
fetchUnitFieldValues, fetchUnitFieldValues,
attachLessonsToUnit, detachLessonFromUnit, detachUnitFromCourse, attachUnitToCourses, reorderUnitLessons, attachLessonsToUnit, detachLessonFromUnit, detachUnitFromCourse, attachUnitToCourses, reorderUnitLessons,
attachLessonToUnits, attachLessonToUnits,
fetchUnitProduct, saveUnitProduct, removeUnitProduct,
// lesson library // lesson library
lessons, lesson, lessonsFlat, lessons, lesson, lessonsFlat,
@@ -521,7 +472,6 @@ export function LibraryProvider({ children }) {
permanentlyDeleteLesson, permanentlyDeleteLessons, permanentlyDeleteLesson, permanentlyDeleteLessons,
fetchLessonPermanentDeleteImpact, fetchLessonPermanentDeleteImpact,
fetchLessonFieldValues, fetchLessonFieldValues,
fetchLessonProduct, saveLessonProduct, removeLessonProduct,
}; };
return <LibraryContext.Provider value={value}>{children}</LibraryContext.Provider>; return <LibraryContext.Provider value={value}>{children}</LibraryContext.Provider>;
-31
View File
@@ -36,12 +36,6 @@ export function ClientLibraryProvider({ children }) {
const [quiz, setQuiz] = useState(null); const [quiz, setQuiz] = useState(null);
const [quizLoading, setQuizLoading] = useState(false); const [quizLoading, setQuizLoading] = useState(false);
// ── Checkout info (unit/lesson individual-purchase page) — deliberately not
// gated by canAccessUnit/canAccessLesson like unitDetail/lesson above, since
// this is exactly what a locked-and-unpurchased learner needs to see.
const [checkoutInfo, setCheckoutInfo] = useState(null);
const [checkoutInfoLoading, setCheckoutInfoLoading] = useState(false);
// ─── Actions ──────────────────────────────────────────────────────────── // ─── Actions ────────────────────────────────────────────────────────────
const getUnits = useCallback(async () => { const getUnits = useCallback(async () => {
@@ -228,26 +222,6 @@ export function ClientLibraryProvider({ children }) {
} }
}, []); }, []);
const getUnitCheckoutInfo = useCallback(async (uuid) => {
setCheckoutInfoLoading(true);
try {
const { data } = await api.get(`/client/units/${uuid}/checkout-info`);
setCheckoutInfo(data.data ?? null);
} catch (err) {
toast(err?.response?.data?.message ?? "Could not load unit.");
} finally { setCheckoutInfoLoading(false); }
}, []);
const getLessonCheckoutInfo = useCallback(async (uuid) => {
setCheckoutInfoLoading(true);
try {
const { data } = await api.get(`/client/lessons/${uuid}/checkout-info`);
setCheckoutInfo(data.data ?? null);
} catch (err) {
toast(err?.response?.data?.message ?? "Could not load lesson.");
} finally { setCheckoutInfoLoading(false); }
}, []);
// ─── Resets ───────────────────────────────────────────────────────────── // ─── Resets ─────────────────────────────────────────────────────────────
const resetUnitDetail = useCallback(() => { const resetUnitDetail = useCallback(() => {
@@ -257,7 +231,6 @@ export function ClientLibraryProvider({ children }) {
}, []); }, []);
const resetLesson = useCallback(() => setLesson(null), []); const resetLesson = useCallback(() => setLesson(null), []);
const resetQuiz = useCallback(() => setQuiz(null), []); const resetQuiz = useCallback(() => setQuiz(null), []);
const resetCheckoutInfo = useCallback(() => setCheckoutInfo(null), []);
// ─── Value ────────────────────────────────────────────────────────────── // ─── Value ──────────────────────────────────────────────────────────────
@@ -267,7 +240,6 @@ export function ClientLibraryProvider({ children }) {
unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo, unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo,
lesson, lessonLoading, lesson, lessonLoading,
quiz, quizLoading, quiz, quizLoading,
checkoutInfo, checkoutInfoLoading,
getUnits, getUnits,
getLessons, getLessons,
@@ -279,13 +251,10 @@ export function ClientLibraryProvider({ children }) {
upsertLessonProgress, upsertLessonProgress,
upsertWatchProgress, upsertWatchProgress,
markComplete, markComplete,
getUnitCheckoutInfo,
getLessonCheckoutInfo,
resetUnitDetail, resetUnitDetail,
resetLesson, resetLesson,
resetQuiz, resetQuiz,
resetCheckoutInfo,
}; };
return ( return (
+2 -1
View File
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useState } from "react";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { resolveAssetSrc } from "@/utils/media.util";
const ProfileContext = createContext(null); const ProfileContext = createContext(null);
@@ -143,7 +144,7 @@ export function ProfileProvider({ children, apiBase = '/client' }) {
const fullName = pi?.name?.full_name ?? ""; const fullName = pi?.name?.full_name ?? "";
const givenName = pi?.name?.given_name ?? ""; const givenName = pi?.name?.given_name ?? "";
const lastName = pi?.name?.last_name ?? ""; const lastName = pi?.name?.last_name ?? "";
const avatarUrl = pi?.avatar?.url ?? ""; const avatarUrl = resolveAssetSrc(pi?.avatar) ?? "";
const occupation = pi?.occupation ?? ""; const occupation = pi?.occupation ?? "";
// ─── Reset helpers ───────────────────────────────────────────────────────── // ─── Reset helpers ─────────────────────────────────────────────────────────
-1
View File
@@ -32,7 +32,6 @@ export const ADMIN_SECTIONS = [
{ key: "units", label: "Units", icon: BookCheck, link: "/admin/units" }, { key: "units", label: "Units", icon: BookCheck, link: "/admin/units" },
{ key: "lessons", label: "Lessons", icon: FileText, link: "/admin/lessons" }, { key: "lessons", label: "Lessons", icon: FileText, link: "/admin/lessons" },
{ key: "tasks", label: "Tasks", icon: ListCheck, link: "/admin/taskList" }, { key: "tasks", label: "Tasks", icon: ListCheck, link: "/admin/taskList" },
], ],
}, },
{ {
-71
View File
@@ -1,71 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { usePurchases } from "./usePurchases";
/**
* Shared PayPal order/capture/cancel flow for a standalone checkout page
* (Unit or Lesson) — mirrors CourseCheckout.jsx's logic exactly, including
* the cancel-vs-capture race-condition guard: a real PayPal cancel redirect
* carries BOTH `cancelled=true` (our own cancelUrl) and `token=<order_id>`
* (PayPal always appends its own token to whatever return/cancel URL it's
* given) — without the `wasCancelled` check below, both the capture effect
* and the cancel effect would fire on the same load, racing a capture call
* against the cancel and surfacing a confusing "not found" error instead of
* a clean cancellation message.
*
* @param {string} targetId - uuid used to reload the page's own detail state
* @param {(id: string) => void} loadTarget - memoized loader, called on mount and again after a successful capture
* @param {string} checkoutPath - this page's own path, used to strip query params after a cancel
*/
export function usePurchaseCheckout({ targetId, loadTarget, checkoutPath }) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { purchaseLoading, createOrder, captureOrder, cancelOrder } = usePurchases();
const returnToken = searchParams.get("token");
const wasCancelled = searchParams.get("cancelled") === "true";
const [capturing, setCapturing] = useState(false);
const capturingRef = useRef(false);
useEffect(() => {
loadTarget(targetId);
}, [targetId]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!returnToken || wasCancelled || capturingRef.current) return;
capturingRef.current = true;
setCapturing(true);
captureOrder(returnToken).then((result) => {
if (result) {
loadTarget(targetId);
navigate(checkoutPath, { replace: true });
} else {
setCapturing(false);
capturingRef.current = false;
}
});
}, [returnToken]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!wasCancelled) return;
const orderId = searchParams.get("token");
if (orderId) cancelOrder(orderId);
// Deferred to a new macrotask, same reasoning as CourseCheckout.jsx: this
// page renders before <Toaster /> in the layout, so a toast fired
// synchronously here races the Toaster's own mount effect and is
// silently dropped.
setTimeout(() => toast("Payment was cancelled."), 0);
navigate(checkoutPath, { replace: true });
}, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps
const buyNow = async (productId) => {
const order = await createOrder(productId);
if (!order) return;
if (!order.approval_url) { toast("Could not get PayPal approval URL."); return; }
window.location.href = order.approval_url;
};
return { capturing, purchaseLoading, buyNow };
}
@@ -15,6 +15,7 @@ import {
import { TablePagination } from '@/components/generic/Table/TablePagination'; import { TablePagination } from '@/components/generic/Table/TablePagination';
import { useAdminCourseReadingProgress } from '@/contexts/AdminCourseReadingProgressContext'; import { useAdminCourseReadingProgress } from '@/contexts/AdminCourseReadingProgressContext';
import { useDateFormat } from '@/hooks/useDateFormat'; import { useDateFormat } from '@/hooks/useDateFormat';
import { streamUrl } from '@/utils/media.util';
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -97,7 +98,7 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{entry && <UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />} {entry && <UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={streamUrl(entry.user.avatar_stream_token)} />}
<div className="min-w-0"> <div className="min-w-0">
<DialogTitle className="truncate"> <DialogTitle className="truncate">
{entry?.user.full_name ?? <span className="italic text-muted-foreground">No name</span>} {entry?.user.full_name ?? <span className="italic text-muted-foreground">No name</span>}
@@ -205,7 +206,7 @@ function UserCard({ entry, onOpen }) {
onClick={() => onOpen(entry)} onClick={() => onOpen(entry)}
className="bg-background w-full text-left border rounded-lg p-4 flex items-center gap-3 hover:bg-accent/10 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="bg-background w-full text-left border rounded-lg p-4 flex items-center gap-3 hover:bg-accent/10 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
> >
<UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} /> <UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={streamUrl(entry.user.avatar_stream_token)} />
<div className="flex-1 min-w-0 space-y-1"> <div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
@@ -1,173 +0,0 @@
import { useEffect, useState } from "react";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
const EMPTY_FORM = { name: "", price: "", currency: "USD", access_days: "", is_active: true };
/**
* Self-contained product-listing editor for individual-purchase pricing —
* fetches on mount, saves via its own button (same convention as
* CompletionRequirementBuilder: fetchFn/saveFn/removeFn + args resolve the
* target server-side, so this one component covers Course/Unit/Lesson).
*/
export default function ProductPricingCard({ label = "this content", fetchFn, saveFn, removeFn, args = [] }) {
const [product, setProduct] = useState(null);
const [form, setForm] = useState(EMPTY_FORM);
const [dirty, setDirty] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
let active = true;
fetchFn(...args).then((prod) => {
if (!active) return;
if (prod) {
setProduct(prod);
setForm({
name: prod.name ?? "",
price: prod.price ?? "",
currency: prod.currency ?? "USD",
access_days: prod.access_days ?? "",
is_active: prod.is_active ?? true,
});
}
setLoading(false);
setDirty(false);
});
return () => { active = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...args]);
const handleChange = (field, value) => {
setForm((prev) => ({ ...prev, [field]: value }));
setDirty(true);
};
const handleSave = async () => {
if (!form.price) return;
setSaving(true);
const saved = await saveFn(...args, {
name: form.name || null,
price: Number(form.price),
currency: form.currency || "USD",
access_days: form.access_days ? Number(form.access_days) : null,
is_active: form.is_active,
});
if (saved) { setProduct(saved); setDirty(false); }
setSaving(false);
};
const handleRemove = async () => {
setSaving(true);
await removeFn(...args);
setProduct(null);
setForm(EMPTY_FORM);
setDirty(false);
setSaving(false);
};
if (loading) {
return (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Spinner className="size-5" />
</div>
);
}
return (
<div className="space-y-5">
<p className="text-xs text-muted-foreground">
Allow learners to purchase {label} individually via PayPal, as an alternative to a tier plan.
</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5 col-span-2">
<Label htmlFor="prod_name">Listing Name</Label>
<Input
id="prod_name"
placeholder="e.g. Real Estate Fundamentals"
value={form.name}
onChange={(e) => handleChange("name", e.target.value)}
/>
<p className="text-xs text-muted-foreground">Defaults to the title if left blank.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="prod_price">Price <span className="text-destructive">*</span></Label>
<Input
id="prod_price"
type="number"
step="0.01"
min={0}
placeholder="0.00"
value={form.price}
onChange={(e) => handleChange("price", e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="prod_currency">Currency</Label>
<Input
id="prod_currency"
maxLength={3}
placeholder="USD"
value={form.currency}
onChange={(e) => handleChange("currency", e.target.value.toUpperCase())}
/>
</div>
<div className="space-y-1.5 col-span-2">
<Label htmlFor="prod_access">Access Duration (days)</Label>
<Input
id="prod_access"
type="number"
min={1}
placeholder="Leave blank for lifetime access"
value={form.access_days}
onChange={(e) => handleChange("access_days", e.target.value)}
/>
</div>
</div>
<div className="flex items-center justify-between pt-1">
<div>
<Label>Listed for Purchase</Label>
<p className="text-xs text-muted-foreground">Show a "Buy" button to learners.</p>
</div>
<Switch
checked={form.is_active}
onCheckedChange={(v) => handleChange("is_active", v)}
/>
</div>
<div className="flex items-center justify-between pt-2 border-t">
{product && (
<Button
type="button"
size="sm"
variant="outline"
className="text-destructive border-destructive/50 hover:bg-destructive/5"
disabled={saving}
onClick={handleRemove}
>
{saving && <Spinner className="h-3 w-3 mr-1.5" />}
Remove Listing
</Button>
)}
<Button
type="button"
size="sm"
className="ml-auto"
disabled={!dirty || saving || !form.price}
onClick={handleSave}
>
{saving && <Spinner className="h-3 w-3 mr-1.5" />}
<Save className="h-3 w-3 mr-1.5" />
{product ? "Update Listing" : "Create Listing"}
</Button>
</div>
</div>
);
}
+1 -1
View File
@@ -37,7 +37,7 @@ export default function AdminDashboard() {
</div> </div>
{/* ── Sections — same array, one DashboardGrid per entry ── */} {/* ── Sections — same array, one DashboardGrid per entry ── */}
<div style={{ paddingTop: '40px' }}> <div className="bg-muted/60 xs:pt-10 md:pt-16 xs:pb-4 md:pb-8">
{ADMIN_SECTIONS.map((s) => ( {ADMIN_SECTIONS.map((s) => (
<div key={s.id} id={s.id} style={{ scrollMarginTop: 'calc(var(--navbar-h) + 40px)' }} className="min-h-64"> <div key={s.id} id={s.id} style={{ scrollMarginTop: 'calc(var(--navbar-h) + 40px)' }} className="min-h-64">
{s.tiles.length > 0 ? ( {s.tiles.length > 0 ? (
@@ -16,6 +16,7 @@ import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
import { timeAgo } from "@/utils/timestamp.util"; import { timeAgo } from "@/utils/timestamp.util";
import { fmtISO } from "@/utils/datetime.util"; import { fmtISO } from "@/utils/datetime.util";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import { streamUrl } from "@/utils/media.util";
const BREADCRUMB = [ const BREADCRUMB = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" }, { label: "Home", icon: <House className="size-4" />, to: "/admin" },
@@ -211,7 +212,7 @@ function ActivityRow({ row, onViewUser }) {
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar size="sm" className="shrink-0"> <Avatar size="sm" className="shrink-0">
<AvatarImage src={row.avatar_url ?? undefined} alt={row.full_name ?? row.email} /> <AvatarImage src={streamUrl(row.avatar_stream_token) ?? undefined} alt={row.full_name ?? row.email} />
<AvatarFallback className="text-xs font-semibold bg-secondary text-secondary-foreground"> <AvatarFallback className="text-xs font-semibold bg-secondary text-secondary-foreground">
{initials(row.full_name, row.email)} {initials(row.full_name, row.email)}
</AvatarFallback> </AvatarFallback>
@@ -20,7 +20,6 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder"; import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
import ProductPricingCard from "@/modules/admin/components/products/ProductPricingCard";
const schema = z.object({ const schema = z.object({
title: z.string().min(1, "Title is required."), title: z.string().min(1, "Title is required."),
@@ -36,7 +35,7 @@ function FieldError({ message }) {
export default function EditLibraryLesson() { export default function EditLibraryLesson() {
const navigate = useNavigate(); const navigate = useNavigate();
const { lessonId } = useParams(); const { lessonId } = useParams();
const { fetchLesson, updateLesson, lesson, loading, fetchLessonProduct, saveLessonProduct, removeLessonProduct } = useLibrary(); const { fetchLesson, updateLesson, lesson, loading } = useLibrary();
const { fetchLessonRequirements, syncLessonRequirements } = useCourses(); const { fetchLessonRequirements, syncLessonRequirements } = useCourses();
const { user } = useAuth(); const { user } = useAuth();
@@ -153,20 +152,6 @@ export default function EditLibraryLesson() {
args={[null, null, lessonId]} args={[null, null, lessonId]}
/> />
</div> </div>
<div className="rounded-lg border bg-card p-6 space-y-4 mt-6">
<div>
<h2 className="text-sm font-semibold">Pricing</h2>
<p className="text-xs text-muted-foreground">Optional individual-purchase listing for this lesson.</p>
</div>
<ProductPricingCard
label="this lesson"
fetchFn={fetchLessonProduct}
saveFn={saveLessonProduct}
removeFn={removeLessonProduct}
args={[lessonId]}
/>
</div>
</div> </div>
</div> </div>
@@ -11,7 +11,6 @@ import { useAuth } from "@/contexts/AuthContext";
import { PageMeta } from "@/contexts/MetadataContext"; import { PageMeta } from "@/contexts/MetadataContext";
import api from "@/utils/api.util"; import api from "@/utils/api.util";
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder"; import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
import ProductPricingCard from "@/modules/admin/components/products/ProductPricingCard";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -36,7 +35,7 @@ function FieldError({ message }) {
export default function EditLibraryUnit() { export default function EditLibraryUnit() {
const navigate = useNavigate(); const navigate = useNavigate();
const { unitId } = useParams(); const { unitId } = useParams();
const { fetchUnit, updateUnit, unit, loading, fetchUnitProduct, saveUnitProduct, removeUnitProduct } = useLibrary(); const { fetchUnit, updateUnit, unit, loading } = useLibrary();
const { fetchUnitRequirements, syncUnitRequirements } = useCourses(); const { fetchUnitRequirements, syncUnitRequirements } = useCourses();
const { user } = useAuth(); const { user } = useAuth();
@@ -153,20 +152,6 @@ export default function EditLibraryUnit() {
args={[null, unitId]} args={[null, unitId]}
/> />
</div> </div>
<div className="rounded-lg border bg-card p-6 space-y-4 mt-6">
<div>
<h2 className="text-sm font-semibold">Pricing</h2>
<p className="text-xs text-muted-foreground">Optional individual-purchase listing for this unit.</p>
</div>
<ProductPricingCard
label="this unit"
fetchFn={fetchUnitProduct}
saveFn={saveUnitProduct}
removeFn={removeUnitProduct}
args={[unitId]}
/>
</div>
</div> </div>
</div> </div>
+2 -1
View File
@@ -18,6 +18,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { ArrowLeft, Save, Plus, Trash2, UserCircle2 } from "lucide-react"; import { ArrowLeft, Save, Plus, Trash2, UserCircle2 } from "lucide-react";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard"; import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { resolveAssetSrc } from "@/utils/media.util";
// ─── Schema ─────────────────────────────────────────────────────────────────── // ─── Schema ───────────────────────────────────────────────────────────────────
const addressSchema = z.object({ const addressSchema = z.object({
@@ -106,7 +107,7 @@ export default function EditUser() {
const info = user.personal_info ?? {}; const info = user.personal_info ?? {};
const name = info.name ?? {}; const name = info.name ?? {};
setAvatarPreview(info.avatar?.url ?? null); setAvatarPreview(resolveAssetSrc(info.avatar) ?? null);
reset({ reset({
acc_type: user.acc_type ?? "user", acc_type: user.acc_type ?? "user",
+2 -1
View File
@@ -18,6 +18,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { BanUserDialog } from "@/components/generic/Dialogs/BanUserDialog"; import { BanUserDialog } from "@/components/generic/Dialogs/BanUserDialog";
import { UnbanDialog } from "@/components/generic/Dialogs/UnbanDialog"; import { UnbanDialog } from "@/components/generic/Dialogs/UnbanDialog";
import { useDateFormat } from "@/hooks/useDateFormat"; import { useDateFormat } from "@/hooks/useDateFormat";
import { resolveAssetSrc } from "@/utils/media.util";
// ─── Helper ─────────────────────────────────────────────────────────────────── // ─── Helper ───────────────────────────────────────────────────────────────────
const StatusBadge = ({ value }) => ( const StatusBadge = ({ value }) => (
@@ -86,7 +87,7 @@ export default function ViewUser() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar className="size-11 shrink-0"> <Avatar className="size-11 shrink-0">
<AvatarImage src={user.personal_info?.avatar?.url ?? undefined} alt={name.full_name ?? user.email} /> <AvatarImage src={resolveAssetSrc(user.personal_info?.avatar) ?? undefined} alt={name.full_name ?? user.email} />
<AvatarFallback className="text-sm font-semibold"> <AvatarFallback className="text-sm font-semibold">
{(name.full_name ?? user.email ?? "?") {(name.full_name ?? user.email ?? "?")
.split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase()} .split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase()}
+2 -1
View File
@@ -18,6 +18,7 @@ import { Input } from '@/components/ui/input'
import { PhoneInput } from '@/components/ui/phone-input' import { PhoneInput } from '@/components/ui/phone-input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { resolveAssetSrc } from '@/utils/media.util'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@/components/ui/sonner'
@@ -59,7 +60,7 @@ export default function IntroPage() {
// ── Derived ──────────────────────────────────────────────────────────────── // ── Derived ────────────────────────────────────────────────────────────────
const avatarUrl = pi.avatar?.url ?? '' const avatarUrl = resolveAssetSrc(pi.avatar) ?? ''
const initials = getInitials(givenName, lastName) const initials = getInitials(givenName, lastName)
const displayEmail = user?.email ?? '' const displayEmail = user?.email ?? ''
@@ -1,25 +1,19 @@
// LessonUpsellModal — shown when a learner clicks a locked standalone Lesson. // LessonUpsellModal — shown when a learner clicks a locked standalone Lesson.
// Mirrors UnitUpsellModal.jsx: unlocks via any course reachable through its // Mirrors UnitUpsellModal.jsx: unlocks via any course reachable through its
// attached Units (aggregated across all of them, since a Lesson can sit in // attached Units (aggregated across all of them, since a Lesson can sit in
// more than one), with an optional direct "Buy" listing if the lesson itself // more than one), plus a generic "View Plans" fallback.
// has an active product, plus a generic "View Plans" fallback.
// Shared by LessonsList and Dashboard. // Shared by LessonsList and Dashboard.
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { LockIcon, ShoppingCart, GraduationCap, Check } from "lucide-react"; import { LockIcon, Check } from "lucide-react";
import ResponsiveModal from "@/components/generic/ResponsiveModal"; import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useDateFormat } from "@/hooks/useDateFormat";
import { resolveTierBadge, cheapestTierSlug } from "@/utils/tierBadge.util"; import { resolveTierBadge, cheapestTierSlug } from "@/utils/tierBadge.util";
export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap = {} }) { export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap = {} }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { fmtCurrency } = useDateFormat();
const courses = lesson?.courses ?? []; const courses = lesson?.courses ?? [];
const purchasable = lesson?.product?.is_active && !lesson?.has_purchased;
const awaitingStarterSet = purchasable && lesson?.purchase_eligible === false;
const canBuy = purchasable && !awaitingStarterSet;
const slug = cheapestTierSlug([lesson?.subscription, ...courses.map((c) => c.subscription)], tierMap); const slug = cheapestTierSlug([lesson?.subscription, ...courses.map((c) => c.subscription)], tierMap);
const { rank, label, cls, panel } = resolveTierBadge(slug, tierMap); const { rank, label, cls, panel } = resolveTierBadge(slug, tierMap);
@@ -33,27 +27,13 @@ export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap
footer={ footer={
<> <>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button> <Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
{canBuy && ( <Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
<Button onClick={() => { onOpenChange(false); navigate(`/lessons/${lesson.uuid}/checkout`); }}>
<ShoppingCart /> Buy {fmtCurrency(lesson.product.price ?? 0, lesson.product.currency ?? "USD")}
</Button>
)}
<Button variant={canBuy ? "outline" : "default"} onClick={() => { onOpenChange(false); navigate("/plans"); }}>
<LockIcon /> View Plans <LockIcon /> View Plans
</Button> </Button>
</> </>
} }
> >
<div className="space-y-6 py-2"> <div className="space-y-6 py-2">
{awaitingStarterSet && (
<div className="flex items-center gap-2.5 p-4 rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40">
<GraduationCap className="size-4 text-amber-700 dark:text-amber-400 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-300">
Complete your plan's starter content to unlock individual purchases like this one.
</p>
</div>
)}
{rank > 0 && ( {rank > 0 && (
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}> <div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
@@ -4,25 +4,20 @@
// renders in place of the page body itself. Shared by UnitDetails and // renders in place of the page body itself. Shared by UnitDetails and
// LessonDetails. // LessonDetails.
// //
// `item` (optional) is the unit/lesson's own subscription/product info, from // `item` (optional) is the unit/lesson's own subscription info, from the 403
// the 403 body's `item` field — lets a deep-link show the same own-tier // body's `item` field — lets a deep-link show the same own-tier badge the
// badge + direct "Buy" option the browse-list upsell modals already do, // browse-list upsell modals already do, instead of only ever pointing at an
// instead of only ever pointing at an attached course. // attached course.
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { LockIcon, Zap, ShoppingCart, GraduationCap } from "lucide-react"; import { LockIcon, Zap } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useDateFormat } from "@/hooks/useDateFormat";
import { resolveTierBadge } from "@/utils/tierBadge.util"; import { resolveTierBadge } from "@/utils/tierBadge.util";
export default function LockedContentPanel({ course, item, tierMap = {}, checkoutPath }) { export default function LockedContentPanel({ course, item, tierMap = {} }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { fmtCurrency } = useDateFormat();
const tier = course?.subscription ? tierMap[course.subscription] : null; const tier = course?.subscription ? tierMap[course.subscription] : null;
const ownTier = item?.subscription ? resolveTierBadge(item.subscription, tierMap) : null; const ownTier = item?.subscription ? resolveTierBadge(item.subscription, tierMap) : null;
const purchasable = item?.product?.is_active && !item?.has_purchased && checkoutPath;
const awaitingStarterSet = purchasable && item?.purchase_eligible === false;
const canBuy = purchasable && !awaitingStarterSet;
return ( return (
<div className="flex flex-col items-center gap-6 py-32 text-center px-4"> <div className="flex flex-col items-center gap-6 py-32 text-center px-4">
@@ -33,27 +28,14 @@ export default function LockedContentPanel({ course, item, tierMap = {}, checkou
<h2 className="text-xl font-semibold">Premium / Exclusive Content</h2> <h2 className="text-xl font-semibold">Premium / Exclusive Content</h2>
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
{ownTier {ownTier
? `This content requires the ${ownTier.label} plan${canBuy ? ", or you can purchase it individually" : ""}.` ? `This content requires the ${ownTier.label} plan.`
: course : course
? `This content is part of "${course.title}"${tier?.name ? ` (${tier.name} plan)` : ""}. Upgrade your plan or view the course to unlock it.` ? `This content is part of "${course.title}"${tier?.name ? ` (${tier.name} plan)` : ""}. Upgrade your plan or view the course to unlock it.`
: "Upgrade your plan to access this content."} : "Upgrade your plan to access this content."}
</p> </p>
</div> </div>
{awaitingStarterSet && (
<div className="flex items-center gap-2.5 p-3 rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40 max-w-sm">
<GraduationCap className="size-4 text-amber-700 dark:text-amber-400 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-300 text-left">
Complete your plan's starter content to unlock individual purchases like this one.
</p>
</div>
)}
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{canBuy && (
<Button variant="outline" onClick={() => navigate(checkoutPath)} className="gap-1.5">
<ShoppingCart className="size-4" /> Buy {fmtCurrency(item.product.price ?? 0, item.product.currency ?? "USD")}
</Button>
)}
<Button onClick={() => navigate('/plans')} className="gap-1.5"> <Button onClick={() => navigate('/plans')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans <Zap className="size-4" /> View Available Plans
</Button> </Button>
@@ -1,25 +1,17 @@
// UnitUpsellModal — shown when a learner clicks a locked standalone Unit. // UnitUpsellModal — shown when a learner clicks a locked standalone Unit.
// Unlocks via any course it's attached to (links to view/buy the course), with // Unlocks via any course it's attached to (links to view/buy the course).
// an optional direct "Buy" listing if the unit itself has an active product
// (same PayPal flow as Courses).
// Shared by UnitsList, UnitDetails, and Dashboard. // Shared by UnitsList, UnitDetails, and Dashboard.
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { LockIcon, ShoppingCart, GraduationCap, Check } from "lucide-react"; import { LockIcon, Check } from "lucide-react";
import ResponsiveModal from "@/components/generic/ResponsiveModal"; import ResponsiveModal from "@/components/generic/ResponsiveModal";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useDateFormat } from "@/hooks/useDateFormat";
import { resolveTierBadge, cheapestTierSlug } from "@/utils/tierBadge.util"; import { resolveTierBadge, cheapestTierSlug } from "@/utils/tierBadge.util";
export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {} }) { export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {} }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { fmtCurrency } = useDateFormat();
const courses = unit?.courses ?? []; const courses = unit?.courses ?? [];
const purchasable = unit?.product?.is_active && !unit?.has_purchased;
// purchase_eligible is undefined for callers that haven't fetched it yet — treat as eligible (no regression).
const awaitingStarterSet = purchasable && unit?.purchase_eligible === false;
const canBuy = purchasable && !awaitingStarterSet;
const slug = cheapestTierSlug([unit?.subscription, ...courses.map((c) => c.subscription)], tierMap); const slug = cheapestTierSlug([unit?.subscription, ...courses.map((c) => c.subscription)], tierMap);
const { rank, label, cls, panel } = resolveTierBadge(slug, tierMap); const { rank, label, cls, panel } = resolveTierBadge(slug, tierMap);
@@ -33,27 +25,13 @@ export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {}
footer={ footer={
<> <>
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button> <Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
{canBuy && ( <Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
<Button onClick={() => { onOpenChange(false); navigate(`/units/${unit.uuid}/checkout`); }}>
<ShoppingCart /> Buy {fmtCurrency(unit.product.price ?? 0, unit.product.currency ?? "USD")}
</Button>
)}
<Button variant={canBuy ? "outline" : "default"} onClick={() => { onOpenChange(false); navigate("/plans"); }}>
<LockIcon /> View Plans <LockIcon /> View Plans
</Button> </Button>
</> </>
} }
> >
<div className="space-y-6 py-2"> <div className="space-y-6 py-2">
{awaitingStarterSet && (
<div className="flex items-center gap-2.5 p-4 rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40">
<GraduationCap className="size-4 text-amber-700 dark:text-amber-400 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-300">
Complete your plan's starter content to unlock individual purchases like this one.
</p>
</div>
)}
{rank > 0 && ( {rank > 0 && (
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}> <div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
+3 -1
View File
@@ -31,6 +31,7 @@ import { useProfile } from "@/contexts/ProfileProvider"
import { useClientTiers } from "@/contexts/ClientTiersProvider" import { useClientTiers } from "@/contexts/ClientTiersProvider"
import { resolveTierBadge } from "@/utils/tierBadge.util" import { resolveTierBadge } from "@/utils/tierBadge.util"
import { useGroup } from "@/contexts/ClientGroupContext" import { useGroup } from "@/contexts/ClientGroupContext"
import { resolveAssetSrc } from "@/utils/media.util"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useDateFormat } from "@/hooks/useDateFormat" import { useDateFormat } from "@/hooks/useDateFormat"
import { AVATAR_COLORS } from "@/data/profile.data" import { AVATAR_COLORS } from "@/data/profile.data"
@@ -216,7 +217,8 @@ function ClientNav() {
const given = user?.personal_info?.name?.given_name ?? "" const given = user?.personal_info?.name?.given_name ?? ""
const last = user?.personal_info?.name?.last_name ?? "" const last = user?.personal_info?.name?.last_name ?? ""
const fullName = given && last ? `${given} ${last}` : (user?.email ?? "") const fullName = given && last ? `${given} ${last}` : (user?.email ?? "")
const avatarUrl = user?.personal_info?.avatar?.url ?? user?.personal_info?.avatar ?? "" const avatarUrl = resolveAssetSrc(user?.personal_info?.avatar)
?? (typeof user?.personal_info?.avatar === 'string' ? user.personal_info.avatar : "")
const email = user?.email ?? "" const email = user?.email ?? ""
const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground' const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
+2 -1
View File
@@ -16,6 +16,7 @@ import {
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Camera, Loader2, Plus, Trash2, ArrowLeft } from "lucide-react"; import { Camera, Loader2, Plus, Trash2, ArrowLeft } from "lucide-react";
import AvatarUploadDialog from "@/components/generic/AvatarUploadDialog"; import AvatarUploadDialog from "@/components/generic/AvatarUploadDialog";
import { resolveAssetSrc } from "@/utils/media.util";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -76,7 +77,7 @@ const EditProfile = () => {
setOccupation(pi.occupation ?? ""); setOccupation(pi.occupation ?? "");
setPhones(pi.phone_number?.length ? pi.phone_number : [emptyPhone()]); setPhones(pi.phone_number?.length ? pi.phone_number : [emptyPhone()]);
setAddresses(pi.addresses?.length ? pi.addresses : [emptyAddress()]); setAddresses(pi.addresses?.length ? pi.addresses : [emptyAddress()]);
setAvatarPreview(pi.avatar?.url ?? ""); setAvatarPreview(resolveAssetSrc(pi.avatar) ?? "");
}, [profile]); }, [profile]);
// ── Phone helpers ────────────────────────────────────────────────────────── // ── Phone helpers ──────────────────────────────────────────────────────────
-190
View File
@@ -1,190 +0,0 @@
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, FileText, CalendarDays, House, Loader2, ShieldCheck } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { usePurchaseCheckout } from "@/hooks/usePurchaseCheckout";
function formatAccess(days) {
if (!days) return "Lifetime access";
if (days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""} access`;
if (days % 30 === 0) return `${days / 30} month${days / 30 > 1 ? "s" : ""} access`;
return `${days} day access`;
}
const PageSkeleton = () => (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-4xl mx-auto px-6 pb-6 space-y-4">
<Skeleton className="h-5 w-48" />
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-8"><Skeleton className="h-64 w-full rounded-xl" /></div>
<div className="lg:col-span-4"><Skeleton className="h-48 w-full rounded-xl" /></div>
</div>
</div>
</div>
);
export default function LessonCheckout() {
const navigate = useNavigate();
const { uuid } = useParams();
const { fmtCurrency } = useDateFormat();
const { checkoutInfo: lesson, checkoutInfoLoading: lessonLoading, getLessonCheckoutInfo } = useLibrary();
const { capturing, purchaseLoading, buyNow } = usePurchaseCheckout({
targetId: uuid,
loadTarget: getLessonCheckoutInfo,
checkoutPath: `/lessons/${uuid}/checkout`,
});
if (capturing) {
return (
<div className="min-h-screen bg-muted flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="size-10 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Confirming your payment…</p>
</div>
</div>
);
}
if (lessonLoading) return <PageSkeleton />;
const product = lesson?.product ?? null;
if (!product) {
return (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-3xl mx-auto px-6 pb-6 space-y-4">
<Card>
<CardContent className="py-10 text-center space-y-4">
<FileText className="size-10 mx-auto text-muted-foreground/50" />
<div>
<h1 className="text-xl font-semibold">Not available for purchase</h1>
<p className="text-sm text-muted-foreground mt-1">
This lesson doesn't have an individual purchase option.
</p>
</div>
<Button onClick={() => navigate(`/lessons/${uuid}`)}>
<ArrowLeft className="size-4" /> Go Back
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/dashboard" },
{ label: "Lessons", to: "/lessons" },
{ label: lesson?.title ?? "Lesson", to: `/lessons/${uuid}` },
{ label: "Checkout" },
];
return (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-5xl mx-auto px-6 pb-10">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* ── Left ── */}
<div className="lg:col-span-8 space-y-6">
<AppBreadcrumb items={breadcrumbItems} />
<Card>
<CardHeader>
<CardTitle>Lesson Details</CardTitle>
<CardDescription>Review what you're about to purchase.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-lg bg-secondary flex items-center justify-center shrink-0">
<FileText className="size-8 text-secondary-foreground" />
</div>
<div className="flex-1 min-w-0 space-y-1">
<h2 className="text-lg font-semibold leading-snug">{lesson?.title}</h2>
{lesson?.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{lesson.description}</p>
)}
</div>
</div>
<Separator />
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<CalendarDays className="size-4 shrink-0" />
<span>{formatAccess(product.access_days)}</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* ── Right / Summary ── */}
<div className="lg:col-span-4">
<Card className="lg:sticky lg:top-24">
<CardHeader>
<CardTitle>Price Summary</CardTitle>
<CardDescription>Payment processed through PayPal.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Lesson Price</span>
<span className="font-medium">{fmtCurrency(product.price, product.currency)}</span>
</div>
<Separator />
<div className="flex justify-between items-center text-lg font-semibold">
<span>Total</span>
<span>{fmtCurrency(product.price, product.currency)}</span>
</div>
{lesson?.has_purchased ? (
<Button size="lg" className="w-full" variant="outline" disabled>
Already Purchased
</Button>
) : lesson?.purchase_eligible === false ? (
<div className="space-y-2">
<Button size="lg" className="w-full" variant="outline" disabled>
Not Yet Available
</Button>
<p className="text-xs text-muted-foreground text-center">
Complete your plan's starter content to unlock this purchase.
</p>
</div>
) : (
<Button
size="lg"
className="w-full"
onClick={() => buyNow(product.id)}
disabled={purchaseLoading}
>
{purchaseLoading
? <Loader2 className="size-4 animate-spin" />
: <ShieldCheck className="size-4" />
}
Pay {fmtCurrency(product.price, product.currency)} with PayPal
</Button>
)}
<div className="text-center text-xs text-muted-foreground space-y-1">
<p className="inline-flex items-center justify-center gap-1">
<ShieldCheck className="size-3.5" />
Secure payment powered by PayPal
</p>
<p>Access starts after successful payment capture.</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}
@@ -183,7 +183,6 @@ const LessonDetails = () => {
course={unitBlockedInfo?.course} course={unitBlockedInfo?.course}
item={unitBlockedInfo?.item} item={unitBlockedInfo?.item}
tierMap={tierMap} tierMap={tierMap}
checkoutPath={unitBlockedInfo?.item?.uuid ? `/lessons/${unitBlockedInfo.item.uuid}/checkout` : null}
/> />
); );
} }
+2 -141
View File
@@ -107,148 +107,9 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
return ( return (
<> <>
<Card <div className="border">
className={`relative flex flex-col cursor-pointer transition-shadow hover:shadow-md ${isBlockedByOverlap ? "opacity-60" : ""}`} askodasodkas
onClick={() => onView(plan)}
>
<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>
)}
{isBlockedByOverlap && (
<Badge variant="secondary" className="gap-1">
<Lock className="size-3" />
Already Included in Your Plan
</Badge>
)}
<Badge className={badgeCls}>
<Icon />
{tierLabel}
</Badge>
</div> </div>
</div>
{plan.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{plan.description}</p>
)}
<CardDescription>
<span className="text-3xl font-bold text-foreground">
{fmtCurrency(plan.price, plan.currency)}
</span>
{duration && (
<span className="text-sm text-muted-foreground ml-1">/ {duration}</span>
)}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 space-y-4">
{features.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
What's included
</p>
<ul className="space-y-1.5">
{features.slice(0, 4).map((f, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<Check className="size-3.5 mt-0.5 shrink-0 text-green-500" />
<span>{f.text}</span>
</li>
))}
</ul>
</div>
)}
{bundle ? (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
<bundle.icon className="size-3.5" />
Bundle
</p>
<ul className="space-y-1.5">
{previewItems.map((item) => (
<li key={item[bundle.idKey]} 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">{item.title}</span>
<div className="flex items-center gap-2 mt-0.5">
{item.level && (
<span className="text-xs text-muted-foreground capitalize">{item.level}</span>
)}
{formatCourseDuration(item.duration_seconds) && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="size-3" />
{formatCourseDuration(item.duration_seconds)}
</span>
)}
</div>
</div>
</li>
))}
</ul>
{extraCount > 0 && (
<Badge
type="button"
// onClick={() => setCoursesOpen(true)}
// DIALOG DISABLED
variant="secondary"
>
+{extraCount} more {bundle.noun.toLowerCase()}{extraCount !== 1 ? "s" : ""}
</Badge>
)}
</div>
) : !plan.description ? (
<p className="text-sm text-muted-foreground">
Access to all free course content.
</p>
) : null}
</CardContent>
{hasFooterAction && (
<>
<Separator />
<CardFooter className="flex gap-2 pt-4">
{isCurrent && refundSecsLeft > 0 ? (
<Button
className="flex-1"
variant="destructive"
onClick={(e) => { e.stopPropagation(); onRefund(plan); }}
>
<RotateCcw className="size-4" />
Refund ({formatCountdown(refundSecsLeft)})
</Button>
) : !plan.is_active ? (
<Button
className="flex-1"
variant="secondary"
onClick={(e) => { e.stopPropagation(); setNotAvailableOpen(true); }}
>
Not Available
</Button>
) : isBlockedByOverlap ? (
<Button className="flex-1" variant="secondary" disabled>
<Lock className="size-4" />
Already Included
</Button>
) : plan.tier !== "free" ? (
// Already holds this tier (from another plan, past the refund
// window) — repurchasing extends the existing grant's expiry
// rather than being blocked, so still offer a way to buy.
<Button
className="flex-1"
variant="outline"
onClick={(e) => { e.stopPropagation(); onSelect(plan); }}
>
Extend {tierLabel}
</Button>
) : null}
</CardFooter>
</>
)}
</Card>
{/* ── Not Available Dialog ──────────────────────────────────────── */} {/* ── Not Available Dialog ──────────────────────────────────────── */}
<Dialog open={notAvailableOpen} onOpenChange={setNotAvailableOpen}> <Dialog open={notAvailableOpen} onOpenChange={setNotAvailableOpen}>
-190
View File
@@ -1,190 +0,0 @@
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, BookCheck, CalendarDays, House, Loader2, ShieldCheck } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useLibrary } from "@/contexts/ClientLibraryContext";
import { useDateFormat } from "@/hooks/useDateFormat";
import { usePurchaseCheckout } from "@/hooks/usePurchaseCheckout";
function formatAccess(days) {
if (!days) return "Lifetime access";
if (days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? "s" : ""} access`;
if (days % 30 === 0) return `${days / 30} month${days / 30 > 1 ? "s" : ""} access`;
return `${days} day access`;
}
const PageSkeleton = () => (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-4xl mx-auto px-6 pb-6 space-y-4">
<Skeleton className="h-5 w-48" />
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-8"><Skeleton className="h-64 w-full rounded-xl" /></div>
<div className="lg:col-span-4"><Skeleton className="h-48 w-full rounded-xl" /></div>
</div>
</div>
</div>
);
export default function UnitCheckout() {
const navigate = useNavigate();
const { uuid } = useParams();
const { fmtCurrency } = useDateFormat();
const { checkoutInfo: unit, checkoutInfoLoading: unitLoading, getUnitCheckoutInfo } = useLibrary();
const { capturing, purchaseLoading, buyNow } = usePurchaseCheckout({
targetId: uuid,
loadTarget: getUnitCheckoutInfo,
checkoutPath: `/units/${uuid}/checkout`,
});
if (capturing) {
return (
<div className="min-h-screen bg-muted flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<Loader2 className="size-10 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Confirming your payment…</p>
</div>
</div>
);
}
if (unitLoading) return <PageSkeleton />;
const product = unit?.product ?? null;
if (!product) {
return (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-3xl mx-auto px-6 pb-6 space-y-4">
<Card>
<CardContent className="py-10 text-center space-y-4">
<BookCheck className="size-10 mx-auto text-muted-foreground/50" />
<div>
<h1 className="text-xl font-semibold">Not available for purchase</h1>
<p className="text-sm text-muted-foreground mt-1">
This unit doesn't have an individual purchase option.
</p>
</div>
<Button onClick={() => navigate(`/units/${uuid}`)}>
<ArrowLeft className="size-4" /> Go Back
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/dashboard" },
{ label: "Units", to: "/units" },
{ label: unit?.title ?? "Unit", to: `/units/${uuid}` },
{ label: "Checkout" },
];
return (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-5xl mx-auto px-6 pb-10">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* ── Left ── */}
<div className="lg:col-span-8 space-y-6">
<AppBreadcrumb items={breadcrumbItems} />
<Card>
<CardHeader>
<CardTitle>Unit Details</CardTitle>
<CardDescription>Review what you're about to purchase.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-4">
<div className="w-16 h-16 rounded-lg bg-secondary flex items-center justify-center shrink-0">
<BookCheck className="size-8 text-secondary-foreground" />
</div>
<div className="flex-1 min-w-0 space-y-1">
<h2 className="text-lg font-semibold leading-snug">{unit?.title}</h2>
{unit?.description && (
<p className="text-sm text-muted-foreground line-clamp-2">{unit.description}</p>
)}
</div>
</div>
<Separator />
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<CalendarDays className="size-4 shrink-0" />
<span>{formatAccess(product.access_days)}</span>
</div>
</div>
</CardContent>
</Card>
</div>
{/* ── Right / Summary ── */}
<div className="lg:col-span-4">
<Card className="lg:sticky lg:top-24">
<CardHeader>
<CardTitle>Price Summary</CardTitle>
<CardDescription>Payment processed through PayPal.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Unit Price</span>
<span className="font-medium">{fmtCurrency(product.price, product.currency)}</span>
</div>
<Separator />
<div className="flex justify-between items-center text-lg font-semibold">
<span>Total</span>
<span>{fmtCurrency(product.price, product.currency)}</span>
</div>
{unit?.has_purchased ? (
<Button size="lg" className="w-full" variant="outline" disabled>
Already Purchased
</Button>
) : unit?.purchase_eligible === false ? (
<div className="space-y-2">
<Button size="lg" className="w-full" variant="outline" disabled>
Not Yet Available
</Button>
<p className="text-xs text-muted-foreground text-center">
Complete your plan's starter content to unlock this purchase.
</p>
</div>
) : (
<Button
size="lg"
className="w-full"
onClick={() => buyNow(product.id)}
disabled={purchaseLoading}
>
{purchaseLoading
? <Loader2 className="size-4 animate-spin" />
: <ShieldCheck className="size-4" />
}
Pay {fmtCurrency(product.price, product.currency)} with PayPal
</Button>
)}
<div className="text-center text-xs text-muted-foreground space-y-1">
<p className="inline-flex items-center justify-center gap-1">
<ShieldCheck className="size-3.5" />
Secure payment powered by PayPal
</p>
<p>Access starts after successful payment capture.</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}
-1
View File
@@ -145,7 +145,6 @@ const UnitDetails = () => {
course={unitBlockedInfo?.course} course={unitBlockedInfo?.course}
item={unitBlockedInfo?.item} item={unitBlockedInfo?.item}
tierMap={tierMap} tierMap={tierMap}
checkoutPath={unitBlockedInfo?.item?.uuid ? `/units/${unitBlockedInfo.item.uuid}/checkout` : null}
/> />
); );
} }
@@ -21,8 +21,6 @@ import PlanList from '../pages/PlanList'
import ViewPlan from '../pages/ViewPlan' import ViewPlan from '../pages/ViewPlan'
import ViewTask from '../pages/ViewTask' import ViewTask from '../pages/ViewTask'
import CourseCheckout from '../pages/CourseCheckout' import CourseCheckout from '../pages/CourseCheckout'
import UnitCheckout from '../pages/UnitCheckout'
import LessonCheckout from '../pages/LessonCheckout'
import MyCertificates from '../pages/MyCertificates' import MyCertificates from '../pages/MyCertificates'
import MyAchievements from '../pages/MyAchievements' import MyAchievements from '../pages/MyAchievements'
import MyCompletedContent from '../pages/MyCompletedContent' import MyCompletedContent from '../pages/MyCompletedContent'
@@ -108,7 +106,6 @@ export const ClientRoutes = {
children: [ children: [
{ index: true, element: <UnitDetails /> }, { index: true, element: <UnitDetails /> },
{ path: 'read', element: <UnitReader />, handle: { showFooter: false } }, { path: 'read', element: <UnitReader />, handle: { showFooter: false } },
{ path: 'checkout', element: <UnitCheckout /> },
], ],
}, },
], ],
@@ -123,7 +120,6 @@ export const ClientRoutes = {
path: ':uuid', element: <Outlet />, path: ':uuid', element: <Outlet />,
children: [ children: [
{ index: true, element: <LessonDetails /> }, { index: true, element: <LessonDetails /> },
{ path: 'checkout', element: <LessonCheckout /> },
], ],
}, },
], ],