Skip to content

Commit 1cc23cc

Browse files
committed
feat(checkout): implement checkout flow for services
1 parent d7d9087 commit 1cc23cc

6 files changed

Lines changed: 719 additions & 22 deletions

File tree

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import Link from "next/link";
5+
import { ArrowLeft } from "lucide-react";
6+
import { toast } from "sonner";
7+
8+
import { Badge } from "@/components/ui/badge";
9+
import { Button } from "@/components/ui/button";
10+
import {
11+
Card,
12+
CardContent,
13+
CardFooter,
14+
CardHeader,
15+
} from "@/components/ui/card";
16+
import { Separator } from "@/components/ui/separator";
17+
import {
18+
Stepper,
19+
StepperIndicator,
20+
StepperItem,
21+
StepperList,
22+
StepperSeparator,
23+
StepperTitle,
24+
StepperTrigger,
25+
} from "@/components/ui/stepper";
26+
import type { ServiceView } from "@/app/(authenticated)/services/queries";
27+
import type { ProductDiscountForUser } from "@/lib/stripe";
28+
29+
import { checkoutServiceBooking, startPrivateLessonCheckout } from "../actions";
30+
import {
31+
AvailabilityCalendar,
32+
type AvailabilityRange,
33+
} from "./availability-calendar";
34+
35+
type CheckoutFlowProps = {
36+
service: ServiceView;
37+
discount: ProductDiscountForUser | null;
38+
};
39+
40+
function formatPrice(cents: number | null, currency: string | null) {
41+
if (cents === null) return "—";
42+
const symbol = currency?.toUpperCase() ?? "CAD";
43+
return `$${(cents / 100).toFixed(2)} ${symbol}`;
44+
}
45+
46+
function serviceTypeLabel(type: ServiceView["type"]) {
47+
return type === "private_lessons" ? "Private lesson" : "Program";
48+
}
49+
50+
function applyDiscount(
51+
cents: number,
52+
discount: ProductDiscountForUser,
53+
): number {
54+
if (discount.percentOff !== null) {
55+
return Math.round(cents * (1 - discount.percentOff / 100));
56+
}
57+
if (discount.amountOffCents !== null) {
58+
return Math.max(0, cents - discount.amountOffCents);
59+
}
60+
return cents;
61+
}
62+
63+
type Pricing = {
64+
totalLabel: string;
65+
discount: {
66+
subtotalLabel: string;
67+
savingsLabel: string;
68+
} | null;
69+
};
70+
71+
function buildPricing(
72+
service: ServiceView,
73+
discount: ProductDiscountForUser | null,
74+
): Pricing {
75+
const { priceCents, priceCurrency } = service;
76+
const totalLabel = formatPrice(priceCents, priceCurrency);
77+
78+
if (priceCents === null || discount === null) {
79+
return { totalLabel, discount: null };
80+
}
81+
82+
const finalCents = applyDiscount(priceCents, discount);
83+
if (finalCents >= priceCents) {
84+
return { totalLabel, discount: null };
85+
}
86+
87+
return {
88+
totalLabel: formatPrice(finalCents, priceCurrency),
89+
discount: {
90+
subtotalLabel: formatPrice(priceCents, priceCurrency),
91+
savingsLabel: formatPrice(priceCents - finalCents, priceCurrency),
92+
},
93+
};
94+
}
95+
96+
const STRIPE_BRAND = "#635BFF";
97+
98+
function PoweredByStripe() {
99+
return (
100+
<div className="flex items-center justify-center gap-1.5 text-xs text-muted-foreground">
101+
<span>Powered by</span>
102+
<a
103+
href="https://stripe.com"
104+
target="_blank"
105+
rel="noopener noreferrer"
106+
className="inline-flex items-center gap-1 font-medium transition-opacity hover:opacity-80"
107+
style={{ color: STRIPE_BRAND }}
108+
>
109+
<svg
110+
role="img"
111+
viewBox="0 0 24 24"
112+
xmlns="http://www.w3.org/2000/svg"
113+
className="h-3.5 w-3.5"
114+
fill="currentColor"
115+
aria-hidden="true"
116+
>
117+
<path d="M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.594-7.305h.003z" />
118+
</svg>
119+
Stripe
120+
</a>
121+
</div>
122+
);
123+
}
124+
125+
export function CheckoutFlow({ service, discount }: CheckoutFlowProps) {
126+
const [step, setStep] = React.useState<"confirm" | "availability">(
127+
"confirm",
128+
);
129+
const [submitting, setSubmitting] = React.useState(false);
130+
const [availabilities, setAvailabilities] = React.useState<
131+
AvailabilityRange[]
132+
>([]);
133+
134+
const isPrivateLesson = service.type === "private_lessons";
135+
const pricing = buildPricing(service, discount);
136+
const showAvailabilityStep = isPrivateLesson;
137+
138+
async function handleProgramCheckout() {
139+
setSubmitting(true);
140+
const result = await checkoutServiceBooking({ serviceId: service.id });
141+
if ("error" in result) {
142+
setSubmitting(false);
143+
toast.error(result.error);
144+
return;
145+
}
146+
window.location.href = result.url;
147+
}
148+
149+
async function handlePrivateLessonCheckout() {
150+
if (availabilities.length === 0) {
151+
toast.error("Pick at least one availability window.");
152+
return;
153+
}
154+
setSubmitting(true);
155+
const result = await startPrivateLessonCheckout({
156+
serviceId: service.id,
157+
availabilities,
158+
});
159+
if ("error" in result) {
160+
setSubmitting(false);
161+
toast.error(result.error);
162+
return;
163+
}
164+
window.location.href = result.url;
165+
}
166+
167+
function handleNextOnConfirm() {
168+
if (isPrivateLesson) {
169+
setStep("availability");
170+
return;
171+
}
172+
void handleProgramCheckout();
173+
}
174+
175+
return (
176+
<div className="flex flex-col gap-6">
177+
{showAvailabilityStep && (
178+
<Stepper
179+
value={step}
180+
nonInteractive
181+
className="mx-auto w-full max-w-xs"
182+
>
183+
<StepperList className="w-full">
184+
<StepperItem value="confirm" className="flex-1">
185+
<StepperTrigger className="gap-2">
186+
<StepperIndicator>1</StepperIndicator>
187+
<StepperTitle>Review</StepperTitle>
188+
</StepperTrigger>
189+
<StepperSeparator />
190+
</StepperItem>
191+
<StepperItem value="availability">
192+
<StepperTrigger className="gap-2">
193+
<StepperIndicator>2</StepperIndicator>
194+
<StepperTitle>Availability</StepperTitle>
195+
</StepperTrigger>
196+
</StepperItem>
197+
</StepperList>
198+
</Stepper>
199+
)}
200+
201+
{step === "confirm" ? (
202+
<Card className="overflow-hidden">
203+
<CardHeader className="space-y-4">
204+
<div className="flex items-center justify-between gap-3">
205+
<h1 className="font-heading text-2xl font-semibold leading-tight">
206+
{service.title ?? "Untitled service"}
207+
</h1>
208+
<Badge variant="secondary" className="shrink-0">
209+
{serviceTypeLabel(service.type)}
210+
</Badge>
211+
</div>
212+
{service.description && (
213+
<p className="text-sm leading-relaxed text-muted-foreground">
214+
{service.description}
215+
</p>
216+
)}
217+
</CardHeader>
218+
<CardContent className="space-y-4">
219+
<Separator />
220+
<dl className="space-y-3 text-sm">
221+
<div className="flex items-center justify-between">
222+
<dt className="text-muted-foreground">Duration</dt>
223+
<dd className="font-medium">
224+
{service.durationMinutes} min
225+
</dd>
226+
</div>
227+
{pricing.discount && (
228+
<>
229+
<div className="flex items-center justify-between">
230+
<dt className="text-muted-foreground">
231+
Subtotal
232+
</dt>
233+
<dd className="font-medium">
234+
{pricing.discount.subtotalLabel}
235+
</dd>
236+
</div>
237+
<div className="flex items-center justify-between text-emerald-600">
238+
<dt>Discount</dt>
239+
<dd className="font-medium">
240+
{pricing.discount.savingsLabel}
241+
</dd>
242+
</div>
243+
</>
244+
)}
245+
<div className="flex items-center justify-between">
246+
<dt className="text-muted-foreground">Total</dt>
247+
<dd className="flex items-baseline gap-2">
248+
{pricing.discount && (
249+
<span className="text-sm font-normal text-muted-foreground line-through">
250+
{pricing.discount.subtotalLabel}
251+
</span>
252+
)}
253+
<span className="font-heading text-lg font-semibold">
254+
{pricing.totalLabel}
255+
</span>
256+
</dd>
257+
</div>
258+
</dl>
259+
</CardContent>
260+
<CardFooter className="flex-col gap-1 sm:flex-row sm:justify-between">
261+
<Button asChild variant="ghost" size="sm">
262+
<Link href="/">
263+
<ArrowLeft className="mr-1 h-4 w-4" />
264+
Back
265+
</Link>
266+
</Button>
267+
<Button
268+
onClick={handleNextOnConfirm}
269+
disabled={submitting}
270+
size="lg"
271+
className="w-full sm:w-auto"
272+
>
273+
{submitting ? "Redirecting…" : "Continue to payment"}
274+
</Button>
275+
</CardFooter>
276+
</Card>
277+
) : (
278+
<Card>
279+
<CardHeader className="space-y-2">
280+
<h2 className="font-heading text-xl font-semibold">
281+
Pick your availabilities
282+
</h2>
283+
<p className="text-sm text-muted-foreground">
284+
Click and drag any time blocks that work for you over the
285+
next two weeks. Your coach will confirm a slot from your
286+
choices.
287+
</p>
288+
</CardHeader>
289+
<CardContent>
290+
<AvailabilityCalendar
291+
value={availabilities}
292+
onChange={setAvailabilities}
293+
/>
294+
</CardContent>
295+
<CardFooter className="flex-col gap-3 border-t bg-muted/40 px-6 py-4 sm:flex-row sm:justify-between">
296+
<Button
297+
variant="ghost"
298+
size="sm"
299+
onClick={() => setStep("confirm")}
300+
disabled={submitting}
301+
>
302+
<ArrowLeft className="mr-1 h-4 w-4" />
303+
Back
304+
</Button>
305+
<Button
306+
onClick={handlePrivateLessonCheckout}
307+
disabled={submitting || availabilities.length === 0}
308+
size="lg"
309+
className="w-full sm:w-auto"
310+
>
311+
{submitting ? "Redirecting…" : "Continue to payment"}
312+
</Button>
313+
</CardFooter>
314+
</Card>
315+
)}
316+
317+
<PoweredByStripe />
318+
</div>
319+
);
320+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Loader2Icon } from "lucide-react";
2+
3+
export default function CheckoutLoading() {
4+
return (
5+
<div className="flex w-full max-w-xl items-center justify-center py-24">
6+
<Loader2Icon
7+
role="status"
8+
aria-label="Loading checkout"
9+
className="size-6 animate-spin text-muted-foreground"
10+
/>
11+
</div>
12+
);
13+
}

app/checkout/[productId]/page.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import Link from "next/link";
2+
3+
import { Button } from "@/components/ui/button";
4+
import { createClient } from "@/utils/supabase/server";
5+
import { getProductDiscountForUser } from "@/lib/stripe";
6+
import { getService } from "@/app/(authenticated)/services/queries";
7+
8+
import { CheckoutFlow } from "./checkout-flow";
9+
import { X } from "lucide-react";
10+
11+
export default async function CheckoutPage({
12+
params,
13+
}: {
14+
params: Promise<{ productId: string }>;
15+
}) {
16+
const { productId } = await params;
17+
18+
const supabase = await createClient();
19+
const {
20+
data: { user },
21+
} = await supabase.auth.getUser();
22+
if (!user) {
23+
return <NotAvailable message="You must be signed in to check out." />;
24+
}
25+
26+
const service = await getService(productId);
27+
if (!service || service.status !== "active") {
28+
return <NotAvailable message="This product isn't available." />;
29+
}
30+
31+
const discount = await getProductDiscountForUser({
32+
userId: user.id,
33+
productId: service.stripeProductId,
34+
});
35+
36+
return (
37+
<div className="w-full max-w-xl">
38+
<CheckoutFlow service={service} discount={discount} />
39+
</div>
40+
);
41+
}
42+
43+
function NotAvailable({ message }: { message: string }) {
44+
return (
45+
<div className="flex flex-col items-center justify-center gap-4 w-full max-w-md">
46+
<h1 className="text-xl font-bold text-muted-foreground">
47+
<span className="flex items-center gap-2 text-center">
48+
<X className="size-8 text-red-600" />
49+
{message}
50+
</span>
51+
</h1>
52+
<Button asChild>
53+
<Link href="/">Go back home</Link>
54+
</Button>
55+
</div>
56+
);
57+
}

0 commit comments

Comments
 (0)