mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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 canvas = document.createElement('canvas')
|
||||
canvas.width = outputSize
|
||||
|
||||
@@ -11,8 +11,8 @@ export default function DashboardGrid({ sections = [] }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60">
|
||||
<div className="lg:container lg:mx-auto flex flex-col items-start gap-8 p-4">
|
||||
<section className="">
|
||||
<div className="lg:container lg:mx-auto flex flex-col items-start gap-8 p-6">
|
||||
{sections.map(({ title, description, tiles }) => (
|
||||
<div key={title} className="flex flex-col items-start gap-4 w-full">
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function DashboardGrid({ sections = [] }) {
|
||||
</div>
|
||||
|
||||
{/* 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 }) => (
|
||||
<motion.div
|
||||
key={key}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* Type : Reusable Component
|
||||
* Description : Combined date + time picker for deadline fields.
|
||||
* 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:
|
||||
* value : string | null — ISO datetime string e.g. "2026-06-01T10:30"
|
||||
@@ -12,12 +13,13 @@
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { useState } from 'react';
|
||||
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 { Calendar } from '@/components/ui/calendar';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardFooter } from '@/components/ui/card';
|
||||
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';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -51,7 +53,6 @@ export default function DeadlinePicker({ value, onChange, disabled = false }) {
|
||||
|
||||
const handleDateSelect = (date) => {
|
||||
onChange(buildISO(date, timeStr));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleTimeChange = (e) => {
|
||||
@@ -61,62 +62,67 @@ export default function DeadlinePicker({ value, onChange, disabled = false }) {
|
||||
const handleClear = () => onChange(null);
|
||||
|
||||
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}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className="w-36 justify-between font-normal text-sm"
|
||||
>
|
||||
{selectedDate ? format(selectedDate, 'MMM d, yyyy') : 'Select date'}
|
||||
<ChevronDownIcon className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className="w-56 justify-between font-normal text-sm"
|
||||
>
|
||||
{selectedDate
|
||||
? `${format(selectedDate, 'MMM d, yyyy')} ${format(parseISO(buildISO(selectedDate, timeStr)), 'h:mm a')}`
|
||||
: 'Select date'}
|
||||
<ChevronDownIcon className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto border-0 bg-transparent p-0 shadow-none ring-0" align="start">
|
||||
<Card size="sm" className="w-fit">
|
||||
<CardContent>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
captionLayout="dropdown"
|
||||
defaultMonth={selectedDate}
|
||||
onSelect={handleDateSelect}
|
||||
disabled={disabled}
|
||||
className="p-0"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* ── Time input ───────────────────────────────────────────────── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Time</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={timeStr}
|
||||
onChange={handleTimeChange}
|
||||
disabled={disabled || !selectedDate}
|
||||
step="60"
|
||||
className="w-28 appearance-none bg-background
|
||||
[&::-webkit-calendar-picker-indicator]:hidden
|
||||
[&::-webkit-calendar-picker-indicator]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Clear ────────────────────────────────────────────────────── */}
|
||||
{value && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={handleClear}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col items-stretch gap-3 border-t bg-card">
|
||||
<FieldGroup>
|
||||
<Field orientation="horizontal">
|
||||
<FieldLabel htmlFor="deadline-time">Time</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
id="deadline-time"
|
||||
type="time"
|
||||
step="60"
|
||||
value={timeStr}
|
||||
onChange={handleTimeChange}
|
||||
disabled={disabled || !selectedDate}
|
||||
className="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<Clock2Icon className="text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
{value && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={handleClear}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useProfile } from '@/contexts/ProfileProvider'
|
||||
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 AvatarUploadDialog from '@/components/generic/AvatarUploadDialog'
|
||||
import { resolveAssetSrc } from '@/utils/media.util'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -233,7 +234,7 @@ export default function ProfilePage() {
|
||||
|
||||
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 avatarUrl = info?.avatar?.url ?? null
|
||||
const avatarUrl = resolveAssetSrc(info?.avatar)
|
||||
const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
|
||||
|
||||
// Load fresh profile data on mount
|
||||
|
||||
@@ -448,54 +448,6 @@ export function LibraryProvider({ children }) {
|
||||
[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 ────────────────────────────────────────────────────────────────
|
||||
const value = {
|
||||
// shared table state
|
||||
@@ -511,7 +463,6 @@ export function LibraryProvider({ children }) {
|
||||
fetchUnitFieldValues,
|
||||
attachLessonsToUnit, detachLessonFromUnit, detachUnitFromCourse, attachUnitToCourses, reorderUnitLessons,
|
||||
attachLessonToUnits,
|
||||
fetchUnitProduct, saveUnitProduct, removeUnitProduct,
|
||||
|
||||
// lesson library
|
||||
lessons, lesson, lessonsFlat,
|
||||
@@ -521,7 +472,6 @@ export function LibraryProvider({ children }) {
|
||||
permanentlyDeleteLesson, permanentlyDeleteLessons,
|
||||
fetchLessonPermanentDeleteImpact,
|
||||
fetchLessonFieldValues,
|
||||
fetchLessonProduct, saveLessonProduct, removeLessonProduct,
|
||||
};
|
||||
|
||||
return <LibraryContext.Provider value={value}>{children}</LibraryContext.Provider>;
|
||||
|
||||
@@ -36,12 +36,6 @@ export function ClientLibraryProvider({ children }) {
|
||||
const [quiz, setQuiz] = useState(null);
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
const resetUnitDetail = useCallback(() => {
|
||||
@@ -257,7 +231,6 @@ export function ClientLibraryProvider({ children }) {
|
||||
}, []);
|
||||
const resetLesson = useCallback(() => setLesson(null), []);
|
||||
const resetQuiz = useCallback(() => setQuiz(null), []);
|
||||
const resetCheckoutInfo = useCallback(() => setCheckoutInfo(null), []);
|
||||
|
||||
// ─── Value ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -267,7 +240,6 @@ export function ClientLibraryProvider({ children }) {
|
||||
unitDetail, unitDetailLoading, unitBlocked, unitBlockedInfo,
|
||||
lesson, lessonLoading,
|
||||
quiz, quizLoading,
|
||||
checkoutInfo, checkoutInfoLoading,
|
||||
|
||||
getUnits,
|
||||
getLessons,
|
||||
@@ -279,13 +251,10 @@ export function ClientLibraryProvider({ children }) {
|
||||
upsertLessonProgress,
|
||||
upsertWatchProgress,
|
||||
markComplete,
|
||||
getUnitCheckoutInfo,
|
||||
getLessonCheckoutInfo,
|
||||
|
||||
resetUnitDetail,
|
||||
resetLesson,
|
||||
resetQuiz,
|
||||
resetCheckoutInfo,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useState } from "react";
|
||||
import api from "@/utils/api.util";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { resolveAssetSrc } from "@/utils/media.util";
|
||||
|
||||
const ProfileContext = createContext(null);
|
||||
|
||||
@@ -143,7 +144,7 @@ export function ProfileProvider({ children, apiBase = '/client' }) {
|
||||
const fullName = pi?.name?.full_name ?? "";
|
||||
const givenName = pi?.name?.given_name ?? "";
|
||||
const lastName = pi?.name?.last_name ?? "";
|
||||
const avatarUrl = pi?.avatar?.url ?? "";
|
||||
const avatarUrl = resolveAssetSrc(pi?.avatar) ?? "";
|
||||
const occupation = pi?.occupation ?? "";
|
||||
|
||||
// ─── Reset helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,7 +32,6 @@ export const ADMIN_SECTIONS = [
|
||||
{ key: "units", label: "Units", icon: BookCheck, link: "/admin/units" },
|
||||
{ key: "lessons", label: "Lessons", icon: FileText, link: "/admin/lessons" },
|
||||
{ key: "tasks", label: "Tasks", icon: ListCheck, link: "/admin/taskList" },
|
||||
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 { useAdminCourseReadingProgress } from '@/contexts/AdminCourseReadingProgressContext';
|
||||
import { useDateFormat } from '@/hooks/useDateFormat';
|
||||
import { streamUrl } from '@/utils/media.util';
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -97,7 +98,7 @@ function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
{entry && <UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />}
|
||||
{entry && <UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={streamUrl(entry.user.avatar_stream_token)} />}
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="truncate">
|
||||
{entry?.user.full_name ?? <span className="italic text-muted-foreground">No name</span>}
|
||||
@@ -205,7 +206,7 @@ function UserCard({ entry, onOpen }) {
|
||||
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"
|
||||
>
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
|
||||
{/* ── 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) => (
|
||||
<div key={s.id} id={s.id} style={{ scrollMarginTop: 'calc(var(--navbar-h) + 40px)' }} className="min-h-64">
|
||||
{s.tiles.length > 0 ? (
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
|
||||
import { timeAgo } from "@/utils/timestamp.util";
|
||||
import { fmtISO } from "@/utils/datetime.util";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { streamUrl } from "@/utils/media.util";
|
||||
|
||||
const BREADCRUMB = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
@@ -211,7 +212,7 @@ function ActivityRow({ row, onViewUser }) {
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<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">
|
||||
{initials(row.full_name, row.email)}
|
||||
</AvatarFallback>
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
|
||||
import ProductPricingCard from "@/modules/admin/components/products/ProductPricingCard";
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(1, "Title is required."),
|
||||
@@ -36,7 +35,7 @@ function FieldError({ message }) {
|
||||
export default function EditLibraryLesson() {
|
||||
const navigate = useNavigate();
|
||||
const { lessonId } = useParams();
|
||||
const { fetchLesson, updateLesson, lesson, loading, fetchLessonProduct, saveLessonProduct, removeLessonProduct } = useLibrary();
|
||||
const { fetchLesson, updateLesson, lesson, loading } = useLibrary();
|
||||
const { fetchLessonRequirements, syncLessonRequirements } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
@@ -153,20 +152,6 @@ export default function EditLibraryLesson() {
|
||||
args={[null, null, lessonId]}
|
||||
/>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PageMeta } from "@/contexts/MetadataContext";
|
||||
import api from "@/utils/api.util";
|
||||
import CompletionRequirementBuilder from "@/modules/admin/components/courses/CompletionRequirementBuilder";
|
||||
import ProductPricingCard from "@/modules/admin/components/products/ProductPricingCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -36,7 +35,7 @@ function FieldError({ message }) {
|
||||
export default function EditLibraryUnit() {
|
||||
const navigate = useNavigate();
|
||||
const { unitId } = useParams();
|
||||
const { fetchUnit, updateUnit, unit, loading, fetchUnitProduct, saveUnitProduct, removeUnitProduct } = useLibrary();
|
||||
const { fetchUnit, updateUnit, unit, loading } = useLibrary();
|
||||
const { fetchUnitRequirements, syncUnitRequirements } = useCourses();
|
||||
const { user } = useAuth();
|
||||
|
||||
@@ -153,20 +152,6 @@ export default function EditLibraryUnit() {
|
||||
args={[null, unitId]}
|
||||
/>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { ArrowLeft, Save, Plus, Trash2, UserCircle2 } from "lucide-react";
|
||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||
import { resolveAssetSrc } from "@/utils/media.util";
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
const addressSchema = z.object({
|
||||
@@ -106,7 +107,7 @@ export default function EditUser() {
|
||||
const info = user.personal_info ?? {};
|
||||
const name = info.name ?? {};
|
||||
|
||||
setAvatarPreview(info.avatar?.url ?? null);
|
||||
setAvatarPreview(resolveAssetSrc(info.avatar) ?? null);
|
||||
|
||||
reset({
|
||||
acc_type: user.acc_type ?? "user",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
||||
import { BanUserDialog } from "@/components/generic/Dialogs/BanUserDialog";
|
||||
import { UnbanDialog } from "@/components/generic/Dialogs/UnbanDialog";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { resolveAssetSrc } from "@/utils/media.util";
|
||||
|
||||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||||
const StatusBadge = ({ value }) => (
|
||||
@@ -86,7 +87,7 @@ export default function ViewUser() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<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">
|
||||
{(name.full_name ?? user.email ?? "?")
|
||||
.split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase()}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { PhoneInput } from '@/components/ui/phone-input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { resolveAssetSrc } from '@/utils/media.util'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
@@ -59,7 +60,7 @@ export default function IntroPage() {
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
|
||||
const avatarUrl = pi.avatar?.url ?? ''
|
||||
const avatarUrl = resolveAssetSrc(pi.avatar) ?? ''
|
||||
const initials = getInitials(givenName, lastName)
|
||||
const displayEmail = user?.email ?? ''
|
||||
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
// LessonUpsellModal — shown when a learner clicks a locked standalone Lesson.
|
||||
// Mirrors UnitUpsellModal.jsx: unlocks via any course reachable through its
|
||||
// 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
|
||||
// has an active product, plus a generic "View Plans" fallback.
|
||||
// more than one), plus a generic "View Plans" fallback.
|
||||
// Shared by LessonsList and Dashboard.
|
||||
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { resolveTierBadge, cheapestTierSlug } from "@/utils/tierBadge.util";
|
||||
|
||||
export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap = {} }) {
|
||||
const navigate = useNavigate();
|
||||
const { fmtCurrency } = useDateFormat();
|
||||
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 { rank, label, cls, panel } = resolveTierBadge(slug, tierMap);
|
||||
@@ -33,27 +27,13 @@ export default function LessonUpsellModal({ open, onOpenChange, lesson, tierMap
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
||||
{canBuy && (
|
||||
<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"); }}>
|
||||
<Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
|
||||
<LockIcon /> View Plans
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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 && (
|
||||
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
|
||||
<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
|
||||
// LessonDetails.
|
||||
//
|
||||
// `item` (optional) is the unit/lesson's own subscription/product info, from
|
||||
// the 403 body's `item` field — lets a deep-link show the same own-tier
|
||||
// badge + direct "Buy" option the browse-list upsell modals already do,
|
||||
// instead of only ever pointing at an attached course.
|
||||
// `item` (optional) is the unit/lesson's own subscription info, from the 403
|
||||
// body's `item` field — lets a deep-link show the same own-tier badge the
|
||||
// browse-list upsell modals already do, instead of only ever pointing at an
|
||||
// attached course.
|
||||
|
||||
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 { useDateFormat } from "@/hooks/useDateFormat";
|
||||
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 { fmtCurrency } = useDateFormat();
|
||||
const tier = course?.subscription ? tierMap[course.subscription] : 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 (
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{ownTier
|
||||
? `This content requires the ${ownTier.label} plan${canBuy ? ", or you can purchase it individually" : ""}.`
|
||||
? `This content requires the ${ownTier.label} plan.`
|
||||
: course
|
||||
? `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."}
|
||||
</p>
|
||||
</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 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">
|
||||
<Zap className="size-4" /> View Available Plans
|
||||
</Button>
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
// 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
|
||||
// an optional direct "Buy" listing if the unit itself has an active product
|
||||
// (same PayPal flow as Courses).
|
||||
// Unlocks via any course it's attached to (links to view/buy the course).
|
||||
// Shared by UnitsList, UnitDetails, and Dashboard.
|
||||
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||
import { resolveTierBadge, cheapestTierSlug } from "@/utils/tierBadge.util";
|
||||
|
||||
export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {} }) {
|
||||
const navigate = useNavigate();
|
||||
const { fmtCurrency } = useDateFormat();
|
||||
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 { rank, label, cls, panel } = resolveTierBadge(slug, tierMap);
|
||||
@@ -33,27 +25,13 @@ export default function UnitUpsellModal({ open, onOpenChange, unit, tierMap = {}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
|
||||
{canBuy && (
|
||||
<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"); }}>
|
||||
<Button onClick={() => { onOpenChange(false); navigate("/plans"); }}>
|
||||
<LockIcon /> View Plans
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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 && (
|
||||
<div className={`p-5 rounded-2xl border ${panel.bg} ${panel.border}`}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useProfile } from "@/contexts/ProfileProvider"
|
||||
import { useClientTiers } from "@/contexts/ClientTiersProvider"
|
||||
import { resolveTierBadge } from "@/utils/tierBadge.util"
|
||||
import { useGroup } from "@/contexts/ClientGroupContext"
|
||||
import { resolveAssetSrc } from "@/utils/media.util"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useDateFormat } from "@/hooks/useDateFormat"
|
||||
import { AVATAR_COLORS } from "@/data/profile.data"
|
||||
@@ -216,7 +217,8 @@ function ClientNav() {
|
||||
const given = user?.personal_info?.name?.given_name ?? ""
|
||||
const last = user?.personal_info?.name?.last_name ?? ""
|
||||
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 avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Camera, Loader2, Plus, Trash2, ArrowLeft } from "lucide-react";
|
||||
import AvatarUploadDialog from "@/components/generic/AvatarUploadDialog";
|
||||
import { resolveAssetSrc } from "@/utils/media.util";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -76,7 +77,7 @@ const EditProfile = () => {
|
||||
setOccupation(pi.occupation ?? "");
|
||||
setPhones(pi.phone_number?.length ? pi.phone_number : [emptyPhone()]);
|
||||
setAddresses(pi.addresses?.length ? pi.addresses : [emptyAddress()]);
|
||||
setAvatarPreview(pi.avatar?.url ?? "");
|
||||
setAvatarPreview(resolveAssetSrc(pi.avatar) ?? "");
|
||||
}, [profile]);
|
||||
|
||||
// ── Phone helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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}
|
||||
item={unitBlockedInfo?.item}
|
||||
tierMap={tierMap}
|
||||
checkoutPath={unitBlockedInfo?.item?.uuid ? `/lessons/${unitBlockedInfo.item.uuid}/checkout` : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,148 +107,9 @@ const PlanCard = ({ plan, myTier, tierMap, onSelect, onView, onRefund, refundSec
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
className={`relative flex flex-col cursor-pointer transition-shadow hover:shadow-md ${isBlockedByOverlap ? "opacity-60" : ""}`}
|
||||
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>
|
||||
{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>
|
||||
<div className="border">
|
||||
askodasodkas
|
||||
</div>
|
||||
|
||||
{/* ── Not Available Dialog ──────────────────────────────────────── */}
|
||||
<Dialog open={notAvailableOpen} onOpenChange={setNotAvailableOpen}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -145,7 +145,6 @@ const UnitDetails = () => {
|
||||
course={unitBlockedInfo?.course}
|
||||
item={unitBlockedInfo?.item}
|
||||
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 ViewTask from '../pages/ViewTask'
|
||||
import CourseCheckout from '../pages/CourseCheckout'
|
||||
import UnitCheckout from '../pages/UnitCheckout'
|
||||
import LessonCheckout from '../pages/LessonCheckout'
|
||||
import MyCertificates from '../pages/MyCertificates'
|
||||
import MyAchievements from '../pages/MyAchievements'
|
||||
import MyCompletedContent from '../pages/MyCompletedContent'
|
||||
@@ -108,7 +106,6 @@ export const ClientRoutes = {
|
||||
children: [
|
||||
{ index: true, element: <UnitDetails /> },
|
||||
{ path: 'read', element: <UnitReader />, handle: { showFooter: false } },
|
||||
{ path: 'checkout', element: <UnitCheckout /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -123,7 +120,6 @@ export const ClientRoutes = {
|
||||
path: ':uuid', element: <Outlet />,
|
||||
children: [
|
||||
{ index: true, element: <LessonDetails /> },
|
||||
{ path: 'checkout', element: <LessonCheckout /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user