Skip to content

Commit c7430e8

Browse files
committed
feat: enhance store success page with email masking and resend functionality, improve messaging for download link
1 parent b6a5b2b commit c7430e8

8 files changed

Lines changed: 407 additions & 5 deletions

File tree

‎src/actions/purchase-email.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"use server";
2+
3+
import { headers } from "next/headers";
4+
import { logActivity } from "@/lib/activity-log";
5+
import {
6+
resendPurchaseEmailByStripeSession,
7+
type PurchaseEmailResendResult,
8+
} from "@/lib/purchase-email-resend";
9+
import {
10+
getClientIp,
11+
getPurchaseEmailResendLimiter,
12+
} from "@/lib/rate-limit";
13+
import { getRequestBaseUrl } from "@/lib/request-url";
14+
import { verifyTurnstile } from "@/lib/turnstile";
15+
16+
export async function resendPurchaseConfirmationEmail(
17+
sessionId: string,
18+
turnstileToken?: string | null,
19+
): Promise<PurchaseEmailResendResult> {
20+
const trimmed = sessionId.trim();
21+
if (!trimmed) {
22+
return { ok: false, error: "Missing checkout reference." };
23+
}
24+
25+
const headerList = await headers();
26+
const ip = getClientIp(headerList);
27+
28+
const humanVerified = await verifyTurnstile(turnstileToken, ip);
29+
if (!humanVerified) {
30+
return {
31+
ok: false,
32+
error: "Verification failed. Please refresh and try again.",
33+
};
34+
}
35+
36+
const limiter = getPurchaseEmailResendLimiter();
37+
if (limiter) {
38+
const { success } = await limiter.limit(`${trimmed}:${ip}`);
39+
if (!success) {
40+
return {
41+
ok: false,
42+
error: "Too many resend attempts. Please wait a few minutes.",
43+
};
44+
}
45+
}
46+
47+
const result = await resendPurchaseEmailByStripeSession(
48+
trimmed,
49+
getRequestBaseUrl(headerList),
50+
);
51+
52+
if (result.ok) {
53+
await logActivity({
54+
action: "purchase.email_resent_customer",
55+
entityType: "Purchase",
56+
metadata: { stripeSessionId: trimmed, tokenRotated: true },
57+
actorEmail: "customer",
58+
});
59+
}
60+
61+
return result;
62+
}

‎src/app/(public)/store/success/page.tsx‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import Link from "next/link";
2+
import { PurchaseEmailResend } from "@/components/molecules/PurchaseEmailResend";
3+
import { maskEmail } from "@/lib/purchase-email-resend";
24
import { getStripe, isStripeConfigured } from "@/lib/stripe";
35

46
export const metadata = {
@@ -14,6 +16,7 @@ export default async function StoreSuccessPage({
1416
}: SuccessPageProps) {
1517
const { session_id: sessionId } = await searchParams;
1618
let productName: string | null = null;
19+
let maskedEmail: string | null = null;
1720

1821
if (sessionId && isStripeConfigured()) {
1922
try {
@@ -25,8 +28,16 @@ export default async function StoreSuccessPage({
2528
if (!productName && session.metadata?.productId) {
2629
productName = "your purchase";
2730
}
31+
32+
const buyerEmail =
33+
session.metadata?.buyerEmail ||
34+
session.customer_details?.email ||
35+
session.customer_email;
36+
if (buyerEmail) {
37+
maskedEmail = maskEmail(buyerEmail);
38+
}
2839
} catch {
29-
// Non-blocking — email is the source of truth for the download link
40+
// Non-blocking — resend flow re-verifies the session server-side.
3041
}
3142
}
3243

@@ -43,12 +54,26 @@ export default async function StoreSuccessPage({
4354
{productName
4455
? `Thank you for purchasing ${productName}.`
4556
: "Thank you for your purchase."}{" "}
46-
We&apos;ve sent a secure, one-time download link to your email. It
47-
expires in 24 hours.
57+
Your secure download link is sent to your email and expires in 24
58+
hours.
4859
</p>
4960
<p className="mt-2 text-sm text-zinc-500">
50-
Check your inbox (and spam folder). The link can only be used once.
61+
Check your inbox and spam folder. The link allows a limited number of
62+
downloads.
5163
</p>
64+
65+
{sessionId ? (
66+
<PurchaseEmailResend
67+
sessionId={sessionId}
68+
maskedEmail={maskedEmail}
69+
/>
70+
) : (
71+
<p className="mt-6 text-sm text-zinc-500">
72+
If you don&apos;t receive an email within a few minutes, contact
73+
support with your receipt.
74+
</p>
75+
)}
76+
5277
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:justify-center">
5378
<Link
5479
href="/store"
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"use client";
2+
3+
import { useState, useTransition } from "react";
4+
import { resendPurchaseConfirmationEmail } from "@/actions/purchase-email";
5+
import { TurnstileField } from "@/components/molecules/TurnstileField";
6+
import { isTurnstileClientEnabled } from "@/lib/turnstile";
7+
import { cn } from "@/utils";
8+
9+
const turnstileEnabled = isTurnstileClientEnabled();
10+
11+
interface PurchaseEmailResendProps {
12+
sessionId: string;
13+
maskedEmail?: string | null;
14+
}
15+
16+
export function PurchaseEmailResend({
17+
sessionId,
18+
maskedEmail,
19+
}: PurchaseEmailResendProps) {
20+
const [feedback, setFeedback] = useState<{
21+
type: "success" | "error";
22+
text: string;
23+
} | null>(null);
24+
const [isPending, startTransition] = useTransition();
25+
const [turnstileReady, setTurnstileReady] = useState(!turnstileEnabled);
26+
const [turnstileToken, setTurnstileToken] = useState("");
27+
const [turnstileResetKey, setTurnstileResetKey] = useState(0);
28+
29+
const bumpTurnstile = () => {
30+
setTurnstileReady(!turnstileEnabled);
31+
setTurnstileToken("");
32+
setTurnstileResetKey((key) => key + 1);
33+
};
34+
35+
const handleResend = () => {
36+
setFeedback(null);
37+
startTransition(async () => {
38+
const result = await resendPurchaseConfirmationEmail(
39+
sessionId,
40+
turnstileToken || null,
41+
);
42+
// Turnstile tokens are single-use — always refresh after an attempt.
43+
bumpTurnstile();
44+
45+
if (result.ok) {
46+
setFeedback({ type: "success", text: result.message });
47+
} else {
48+
setFeedback({ type: "error", text: result.error });
49+
}
50+
});
51+
};
52+
53+
return (
54+
<div className="mt-8 rounded-none border border-zinc-100 bg-zinc-50 p-5 text-left">
55+
<p className="text-sm font-medium text-zinc-800">
56+
Didn&apos;t get the email?
57+
</p>
58+
<p className="mt-1 text-sm text-zinc-600">
59+
{maskedEmail
60+
? `We can email a fresh download link to ${maskedEmail}. Any older link will stop working. Check spam first.`
61+
: "We can email a fresh download link. Any older link will stop working. Check spam first."}
62+
</p>
63+
64+
<TurnstileField
65+
resetKey={turnstileResetKey}
66+
onVerifiedChange={setTurnstileReady}
67+
onTokenChange={setTurnstileToken}
68+
/>
69+
70+
<button
71+
type="button"
72+
onClick={handleResend}
73+
disabled={isPending || !turnstileReady}
74+
className={cn(
75+
"mt-4 inline-flex w-full items-center justify-center border border-zinc-300 bg-white px-5 py-2.5 text-sm font-medium tracking-wide text-zinc-800 uppercase transition-colors hover:bg-zinc-100 disabled:opacity-50 sm:w-auto",
76+
)}
77+
>
78+
{isPending ? (
79+
<span className="h-4 w-4 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-800" />
80+
) : (
81+
"Send new download link"
82+
)}
83+
</button>
84+
{feedback && (
85+
<p
86+
className={cn(
87+
"mt-3 text-sm",
88+
feedback.type === "success" ? "text-emerald-700" : "text-red-600",
89+
)}
90+
role="status"
91+
>
92+
{feedback.text}
93+
</p>
94+
)}
95+
</div>
96+
);
97+
}

‎src/components/molecules/TurnstileField.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ interface TurnstileFieldProps {
88
/** Bump to reset the widget (e.g. after a failed submit). */
99
resetKey?: number;
1010
onVerifiedChange?: (verified: boolean) => void;
11+
/** For server actions / fetch handlers that need the raw token. */
12+
onTokenChange?: (token: string) => void;
1113
}
1214

1315
export function TurnstileField({
1416
resetKey = 0,
1517
onVerifiedChange,
18+
onTokenChange,
1619
}: TurnstileFieldProps) {
1720
const widgetRef = useRef<TurnstileInstance>(null);
1821
const [token, setToken] = useState("");
@@ -24,6 +27,7 @@ export function TurnstileField({
2427
prevResetKey.current = resetKey;
2528
setToken("");
2629
onVerifiedChange?.(false);
30+
onTokenChange?.("");
2731
widgetRef.current?.reset();
2832
// eslint-disable-next-line react-hooks/exhaustive-deps -- onVerifiedChange is stable (setState)
2933
}, [resetKey]);
@@ -44,15 +48,18 @@ export function TurnstileField({
4448
options={{ theme: "light", size: "normal" }}
4549
onSuccess={(value) => {
4650
setToken(value);
51+
onTokenChange?.(value);
4752
onVerifiedChange?.(true);
4853
}}
4954
onExpire={() => {
5055
setToken("");
56+
onTokenChange?.("");
5157
onVerifiedChange?.(false);
5258
widgetRef.current?.reset();
5359
}}
5460
onError={() => {
5561
setToken("");
62+
onTokenChange?.("");
5663
onVerifiedChange?.(false);
5764
}}
5865
/>

‎src/lib/email.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,11 @@ export async function sendPurchaseConfirmation({
214214
siteUrl?: string;
215215
}) {
216216
const config = getResendConfig();
217-
if (!config) return;
217+
if (!config) {
218+
throw new Error(
219+
"Purchase confirmation email failed: RESEND_API_KEY or RESEND_FROM_EMAIL not configured.",
220+
);
221+
}
218222

219223
const safeName = escapeHtml(name);
220224
const baseUrl = (siteUrl || getSiteUrl()).replace(/\/$/, "");

0 commit comments

Comments
 (0)