Files
starr-philproperties/src/modules/client/pages/Checkout.jsx
T
2026-08-05 04:32:29 +08:00

444 lines
18 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import { useAuth } from "@/contexts/AuthContext";
import { useProfile } from "@/contexts/ProfileProvider";
import { useClientTiers } from "@/contexts/ClientTiersProvider";
import { PageMeta } from "@/contexts/MetadataContext";
import {
ArrowLeft, BookOpen, CalendarDays, Check,
House, Loader2, ShieldCheck, Tag, Zap, LockIcon,
} from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import api from "@/utils/api.util";
function formatDuration(days, unit) {
if (!days) return "Lifetime";
const UNIT_TO_DAYS = { minute: 1 / 1440, hour: 1 / 24, day: 1, month: 30, year: 365 };
const multiplier = UNIT_TO_DAYS[unit] ?? 1;
const value = Math.round((days / multiplier) * 1000) / 1000;
const label = unit ?? "day";
return `${value} ${label}${value !== 1 ? "s" : ""}`;
}
function formatCourseDuration(seconds = 0) {
if (!seconds) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h && m) return `${h}h ${m}m`;
if (h) return `${h}h`;
return `${m}m`;
}
const TIER_STYLES = {
free: { badge: "bg-gradient-to-r from-lime-400 to-lime-600 text-white", icon: Tag, label: "Free" },
premium: { badge: "bg-gradient-to-r from-fuchsia-600 to-purple-600 text-white", icon: Zap, label: "Premium" },
exclusive: { badge: "bg-gradient-to-r from-rose-500 to-red-600 text-white", icon: LockIcon, label: "Exclusive" },
};
const CheckoutSkeleton = () => (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-6xl mx-auto px-6 pb-6">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-8 space-y-6">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-48 w-full rounded-xl" />
<Skeleton className="h-52 w-full rounded-xl" />
</div>
<div className="lg:col-span-4">
<Skeleton className="h-80 w-full rounded-xl" />
</div>
</div>
</div>
</div>
);
const Checkout = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { fmtCurrency } = useDateFormat();
const planId = searchParams.get("plan_id");
const returnToken = searchParams.get("token");
const wasCancelled = searchParams.get("cancelled") === "true";
const { user } = useAuth();
const { givenName, lastName: lastNameFromProfile, getProfile, profile } = useProfile();
const {
plans, plansLoading,
myTier, tierLoading,
checkoutLoading,
promoLoading,
getPlans, getMyTier,
validatePromo,
createOrder, captureOrder, cancelOrder,
} = useClientTiers();
const [promoCode, setPromoCode] = useState("");
const [promoResult, setPromoResult] = useState(null); // { valid, code, type, value, discount }
const [capturing, setCapturing] = useState(false);
const capturingRef = useRef(false);
useEffect(() => {
getProfile();
}, []);
useEffect(() => {
getMyTier();
if (!plans.length) getPlans();
}, [getMyTier, getPlans, plans.length]);
const plan = useMemo(
() => plans.find((p) => String(p.plan_id) === String(planId)) ?? null,
[plans, planId]
);
// Handle PayPal return after approval
useEffect(() => {
if (!returnToken || capturingRef.current) return;
capturingRef.current = true;
setCapturing(true);
captureOrder(returnToken).then((result) => {
if (result) {
navigate("/plans", { replace: true });
} else {
setCapturing(false);
capturingRef.current = false;
}
});
}, [returnToken, captureOrder, navigate]);
// Handle PayPal return after cancellation
useEffect(() => {
if (!wasCancelled) return;
const orderId = searchParams.get("token");
if (orderId) cancelOrder(orderId);
toast("PayPal checkout was cancelled.");
navigate(`/plans/checkout?plan_id=${planId}`, { replace: true });
}, [wasCancelled]); // eslint-disable-line react-hooks/exhaustive-deps
// const personalInfo = user?.personal_info ?? {};
const firstName = givenName || user?.personal_info?.name?.given_name || "";
const lastName = lastNameFromProfile || user?.personal_info?.name?.last_name || "";
const email = user?.email ?? "";
const isLoading = plansLoading || tierLoading;
// A user can hold more than one active tier concurrently — check membership
// in the full active set, not just the best one.
const isCurrent = !!plan && (myTier?.active_tiers ?? []).some((t) => t.tier === plan.tier);
const style = TIER_STYLES[plan?.tier] ?? TIER_STYLES.free;
const Icon = style.icon;
const effectivePrice = plan ? Number(plan.price) : 0;
const effectiveCurrency = plan?.currency ?? 'USD';
const subtotal = effectivePrice;
const discount = promoResult?.discount ?? 0;
const total = Math.max(subtotal - discount, 0);
const duration = formatDuration(plan?.duration_days, plan?.duration_unit);
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/" },
{ label: "Plans", to: "/plans" },
{ label: "Checkout" },
];
const handleApplyPromo = async () => {
if (!plan) return;
const result = await validatePromo(plan.plan_id, promoCode.trim().toUpperCase());
if (result?.valid) {
setPromoResult(result);
toast("Promo code applied.");
} else {
toast(result?.reason ?? "Invalid promo code.");
}
};
const handleRemovePromo = () => {
setPromoResult(null);
setPromoCode("");
};
const handlePayPal = async () => {
const order = await createOrder(
plan.plan_id,
promoResult?.code ?? null,
);
if (!order) return;
const approvalUrl = order.approval_url;
if (!approvalUrl) { toast("Could not get PayPal approval URL."); return; }
window.location.href = approvalUrl;
};
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 (isLoading) return <CheckoutSkeleton />;
if (!planId || !plan) {
return (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-3xl mx-auto px-6 pb-6 space-y-4">
<AppBreadcrumb items={breadcrumbItems} />
<Card>
<CardContent className="py-10 text-center space-y-4">
<BookOpen className="size-10 mx-auto text-muted-foreground/50" />
<div>
<h1 className="text-xl font-semibold">No plan selected</h1>
<p className="text-sm text-muted-foreground mt-1">
Select a subscription plan before continuing to checkout.
</p>
</div>
<Button onClick={() => navigate("/plans")}>
<ArrowLeft className="size-4" /> Back to Plans
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
if (!plan.is_active) {
return (
<div className="min-h-screen bg-muted pt-24">
<div className="max-w-3xl mx-auto px-6 pb-6 space-y-4">
<AppBreadcrumb items={breadcrumbItems} />
<Card>
<CardContent className="py-10 text-center space-y-4">
<LockIcon className="size-10 mx-auto text-muted-foreground/50" />
<div>
<h1 className="text-xl font-semibold">Plan Not Available</h1>
<p className="text-sm text-muted-foreground mt-1">
The <span className="font-medium text-foreground">{plan.label}</span> plan
is not available for purchase at the moment.
</p>
</div>
<Button onClick={() => navigate("/plans")}>
<ArrowLeft className="size-4" /> Back to Plans
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-muted pt-24">
<PageMeta title={plan ? `Checkout – ${plan.label} - STARR` : undefined} />
<div className="max-w-6xl mx-auto px-6 pb-6">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<div className="lg:col-span-8 space-y-6">
<AppBreadcrumb items={breadcrumbItems} />
<Card>
<CardHeader>
<CardTitle>Review Plan</CardTitle>
<CardDescription>Confirm the subscription you are about to purchase.</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex items-start flex-col sm:flex-row gap-4">
<div className="w-full sm:w-32 h-32 bg-secondary rounded-xl flex items-center justify-center">
<Icon className="size-12 text-secondary-foreground" />
</div>
<div className="flex-1 space-y-3">
<div className="flex items-center gap-2 flex-wrap">
<Badge className={style.badge}>
<Icon className="size-3.5" /> {style.label}
</Badge>
<Badge variant="outline" className="gap-1">
<CalendarDays className="size-3.5" /> {duration}
</Badge>
</div>
<div>
<h1 className="text-2xl font-semibold">{plan.label}</h1>
<p className="text-sm text-muted-foreground mt-1">
{plan.course_count ?? plan.courses?.length ?? 0} course
{(plan.course_count ?? plan.courses?.length ?? 0) === 1 ? "" : "s"} included
</p>
</div>
<div className="flex items-center gap-3 flex-wrap">
<p className="text-2xl font-bold text-primary">
{fmtCurrency(effectivePrice, effectiveCurrency)}
</p>
</div>
</div>
</div>
<Separator />
<div className="space-y-3">
<p className="text-sm font-medium flex items-center gap-2">
<BookOpen className="size-4" /> Bundle
</p>
{plan.courses?.length > 0 ? (
<div className="space-y-3">
{plan.courses.map((course) => (
<div key={course.course_id} className="flex items-start gap-3">
<Check className="size-4 text-green-500 mt-0.5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium line-clamp-1">{course.title}</p>
<div className="flex items-center gap-2 flex-wrap mt-1">
{course.level && (
<Badge variant="outline" className="h-5 text-xs capitalize">
{course.level}
</Badge>
)}
{formatCourseDuration(course.duration_seconds) && (
<span className="text-xs text-muted-foreground">
{formatCourseDuration(course.duration_seconds)}
</span>
)}
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">Access to free course content.</p>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Billing Information</CardTitle>
<CardDescription>This uses the profile details on your account.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid lg:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<Input id="firstName" value={firstName} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<Input id="lastName" value={lastName} disabled />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input id="email" type="email" value={email} disabled />
</div>
</CardContent>
</Card>
</div>
<div className="lg:col-span-4">
<Card className="lg:sticky lg:top-24">
<CardHeader>
<CardTitle>Price Breakdown</CardTitle>
<CardDescription>Payment will be processed through PayPal.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">Plan Price</span>
<span className="font-medium">{fmtCurrency(subtotal, effectiveCurrency)}</span>
</div>
{promoResult?.valid && (
<div className="flex justify-between gap-4 text-green-600">
<span>Promo ({promoResult.code})</span>
<span>-{fmtCurrency(promoResult.discount, effectiveCurrency)}</span>
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="promo">Promo Code</Label>
{promoResult?.valid ? (
<div className="flex items-center gap-2 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
<Check className="size-4 shrink-0" />
<span className="flex-1 font-medium">{promoResult.code}</span>
<button
onClick={handleRemovePromo}
className="text-green-500 hover:text-green-700 text-xs underline"
>
Remove
</button>
</div>
) : (
<div className="flex gap-2">
<Input
id="promo"
placeholder="Enter promo code"
value={promoCode}
onChange={(e) => setPromoCode(e.target.value)}
disabled={promoLoading || checkoutLoading}
onKeyDown={(e) => e.key === "Enter" && promoCode.trim() && handleApplyPromo()}
/>
<Button
variant="outline"
onClick={handleApplyPromo}
disabled={promoLoading || checkoutLoading || !promoCode.trim()}
>
{promoLoading ? <Loader2 className="size-4 animate-spin" /> : "Apply"}
</Button>
</div>
)}
</div>
<Separator />
<div className="flex justify-between items-center text-lg font-semibold">
<span>Total</span>
<span>{fmtCurrency(total, effectiveCurrency)}</span>
</div>
{isCurrent && (
<p className="text-xs text-muted-foreground rounded-md border bg-muted/50 px-3 py-2">
You already have an active {plan.tier} plan — this purchase will extend it by {duration}, instead of starting a new one.
</p>
)}
<Button
size="lg"
className="w-full"
onClick={handlePayPal}
disabled={checkoutLoading}
>
{checkoutLoading
? <Loader2 className="size-4 animate-spin" />
: <ShieldCheck className="size-4" />
}
{isCurrent
? `Extend for ${fmtCurrency(total, effectiveCurrency)} with PayPal`
: `Pay ${fmtCurrency(total, effectiveCurrency)} with PayPal`}
</Button>
<div className="text-center text-sm text-muted-foreground space-y-1">
<p className="inline-flex items-center justify-center gap-1">
<ShieldCheck className="size-4" />
Secure payment powered by PayPal
</p>
<p>Access starts after successful payment capture.</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
};
export default Checkout;