mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
444 lines
18 KiB
React
444 lines
18 KiB
React
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; |