@carrierllc/mcp 0.13.40 → 0.13.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-WVXII7ZR.js → chunk-4XUAUQ3U.js} +67 -7
- package/dist/chunk-4XUAUQ3U.js.map +1 -0
- package/dist/cli.js +56 -10
- package/dist/cli.js.map +1 -1
- package/dist/index.js +71 -17
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/plugin/carrier/skills/wallet/SKILL.md +1 -1
- package/templates/storefront/CLERK.md +23 -0
- package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +29 -22
- package/templates/storefront/src/app/activate/[orderId]/page.tsx +2 -2
- package/templates/storefront/src/app/api/checkout/claim/route.ts +7 -6
- package/templates/storefront/src/app/api/checkout/guest/route.ts +72 -17
- package/templates/storefront/src/app/api/orders/[orderId]/route.ts +2 -2
- package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +51 -25
- package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +165 -131
- package/templates/storefront/src/app/checkout/success/FocusHeading.tsx +20 -0
- package/templates/storefront/src/app/checkout/success/page.tsx +136 -15
- package/templates/storefront/src/app/dashboard/EsimControls.tsx +20 -24
- package/templates/storefront/src/app/dashboard/page.tsx +43 -23
- package/templates/storefront/src/app/globals.css +44 -5
- package/templates/storefront/src/app/help/page.tsx +2 -2
- package/templates/storefront/src/app/layout.tsx +5 -3
- package/templates/storefront/src/app/page.tsx +27 -7
- package/templates/storefront/src/app/shop/ShopClient.tsx +106 -72
- package/templates/storefront/src/app/shop/page.tsx +7 -2
- package/templates/storefront/src/app/sign-in/[[...sign-in]]/StorefrontSignInClient.tsx +297 -0
- package/templates/storefront/src/app/sign-in/[[...sign-in]]/page.tsx +13 -14
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +31 -28
- package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +5 -2
- package/templates/storefront/src/brand.config.ts +16 -0
- package/templates/storefront/src/components/Providers.tsx +20 -1
- package/templates/storefront/src/components/faq/FaqSection.tsx +2 -2
- package/templates/storefront/src/components/footer/StorefrontFooter.tsx +7 -7
- package/templates/storefront/src/components/landing/FinalCTA.tsx +4 -9
- package/templates/storefront/src/components/landing/HeroSection.tsx +4 -7
- package/templates/storefront/src/components/landing/HowItWorks.tsx +1 -1
- package/templates/storefront/src/components/landing/PlanCard.tsx +23 -13
- package/templates/storefront/src/components/landing/TierPlanBoard.tsx +237 -0
- package/templates/storefront/src/components/nav/StorefrontNav.tsx +8 -11
- package/templates/storefront/src/components/shop/CountrySearch.tsx +94 -0
- package/templates/storefront/src/components/theme/BrandStyles.tsx +2 -2
- package/templates/storefront/src/components/theme/ThemeToggle.tsx +1 -1
- package/templates/storefront/src/components/theme/brand-accent-css.ts +21 -0
- package/templates/storefront/src/components/theme/brand-style.ts +88 -0
- package/templates/storefront/src/components/theme/clerk-appearance.ts +18 -2
- package/templates/storefront/src/lib/buyer-auth-copy.ts +83 -0
- package/templates/storefront/src/lib/buyer-error.ts +32 -0
- package/templates/storefront/src/lib/buyer-session.ts +41 -0
- package/templates/storefront/src/lib/catalog-countries.ts +179 -0
- package/templates/storefront/src/lib/checkout-client-error.ts +28 -0
- package/templates/storefront/src/lib/checkout-copy.ts +89 -0
- package/templates/storefront/src/lib/checkout-order-claim.ts +8 -7
- package/templates/storefront/src/lib/checkout-return.ts +18 -0
- package/templates/storefront/src/lib/clerk-instance.ts +89 -0
- package/templates/storefront/src/lib/order-reference.ts +15 -0
- package/templates/storefront/src/lib/request-origin.ts +28 -0
- package/templates/storefront/src/lib/sanitize-auth-redirect.ts +20 -4
- package/templates/storefront/src/lib/tap-target.ts +17 -0
- package/templates/storefront/src/lib/verify-checkout-session.ts +118 -19
- package/templates/storefront/src/middleware.ts +11 -0
- package/templates/storefront/src/vendor/carrier/catalog-guard.ts +9 -5
- package/templates/storefront/src/vendor/carrier/client.ts +22 -4
- package/templates/storefront/src/vendor/carrier/country-offers.ts +106 -0
- package/templates/storefront/src/vendor/carrier/stripe-price.ts +134 -0
- package/templates/storefront/src/vendor/carrier/tiers.ts +161 -0
- package/templates/storefront/src/vendor/carrier/types.ts +22 -0
- package/templates/storefront/src/vendor/geo/index.ts +15 -4
- package/dist/chunk-WVXII7ZR.js.map +0 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { auth } from "@clerk/nextjs/server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Buyers share a Clerk instance that can leave a new session pending on
|
|
5
|
+
* choose-organization. Buyers have no organization, so that one task still
|
|
6
|
+
* counts as signed in. A session pending on password reset or MFA does not:
|
|
7
|
+
* it must not read orders.
|
|
8
|
+
*/
|
|
9
|
+
const BUYER_PENDING_TASK = "choose-organization";
|
|
10
|
+
|
|
11
|
+
export function buyerPendingTasksAllowed(tasks: readonly string[]): boolean {
|
|
12
|
+
return tasks.length > 0 && tasks.every((task) => task === BUYER_PENDING_TASK);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function pendingSessionTasks(sessionId: string): Promise<string[] | null> {
|
|
16
|
+
const secretKey = process.env.CLERK_SECRET_KEY?.trim();
|
|
17
|
+
if (!secretKey) return null;
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(`https://api.clerk.com/v1/sessions/${encodeURIComponent(sessionId)}`, {
|
|
20
|
+
headers: { Authorization: `Bearer ${secretKey}` },
|
|
21
|
+
cache: "no-store",
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok) return null;
|
|
24
|
+
const session = (await res.json()) as { status?: unknown; tasks?: unknown };
|
|
25
|
+
if (session.status !== "pending" || !Array.isArray(session.tasks)) return null;
|
|
26
|
+
return session.tasks.map((task) => String((task as { key?: unknown } | null)?.key ?? ""));
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Signed-in buyer id, including a session whose only pending task is choose-organization. */
|
|
33
|
+
export async function buyerUserId(): Promise<string | null> {
|
|
34
|
+
const { userId, sessionId, sessionStatus } = await auth({ treatPendingAsSignedOut: false });
|
|
35
|
+
if (!userId) return null;
|
|
36
|
+
if (sessionStatus !== "pending") return userId;
|
|
37
|
+
if (!sessionId) return null;
|
|
38
|
+
const tasks = await pendingSessionTasks(sessionId);
|
|
39
|
+
if (!tasks || !buyerPendingTasksAllowed(tasks)) return null;
|
|
40
|
+
return userId;
|
|
41
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ISO country codes for the plans this template can sell today.
|
|
3
|
+
*
|
|
4
|
+
* The 44 Europe codes and the 44 destination codes are the countries the
|
|
5
|
+
* current shop sells. Together they are 88, with no overlap. The 160+ bar
|
|
6
|
+
* is a catalog decision and is not filled in here.
|
|
7
|
+
*
|
|
8
|
+
* Those codes belong to the eSIMMY shop. Other storefronts keep whatever
|
|
9
|
+
* coverage the plan already lists. Set NEXT_PUBLIC_COUNTRY_CATALOG=esimmy
|
|
10
|
+
* to expand Europe, Schengen and Global to this list.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const ESIMMY_EUROPE_CODES = [
|
|
14
|
+
"AL", "AD", "AT", "BE", "BA", "BG", "HR", "CY", "CZ", "DK",
|
|
15
|
+
"EE", "FO", "FI", "FR", "GE", "DE", "GI", "GR", "HU", "IS",
|
|
16
|
+
"IE", "IT", "LV", "LI", "LT", "LU", "MT", "MD", "ME", "NL",
|
|
17
|
+
"MK", "NO", "PL", "PT", "RO", "RS", "SK", "SI", "ES", "SE",
|
|
18
|
+
"CH", "TR", "UA", "GB",
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
/** The 26 countries the Schengen sample plan advertises. All sit inside Europe. */
|
|
22
|
+
export const ESIMMY_SCHENGEN_CODES = [
|
|
23
|
+
"AT", "BE", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU",
|
|
24
|
+
"IS", "IT", "LV", "LI", "LT", "LU", "MT", "NL", "NO", "PL",
|
|
25
|
+
"PT", "SK", "SI", "ES", "SE", "CH",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
export const ESIMMY_DESTINATION_CODES = [
|
|
29
|
+
"AR", "AU", "BH", "BR", "KH", "CA", "CL", "CN", "CO", "CR",
|
|
30
|
+
"FJ", "HK", "IN", "ID", "IL", "JM", "JP", "JO", "KE", "KW",
|
|
31
|
+
"LA", "MY", "MV", "MU", "MX", "NP", "NZ", "OM", "PA", "PE",
|
|
32
|
+
"PH", "QA", "SA", "SG", "ZA", "KR", "LK", "TW", "TZ", "TH",
|
|
33
|
+
"TN", "AE", "US", "VN",
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
export const ESIMMY_SOLD_COUNTRY_CODES: readonly string[] = [
|
|
37
|
+
...ESIMMY_EUROPE_CODES,
|
|
38
|
+
...ESIMMY_DESTINATION_CODES,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const REGION_LABELS: Record<string, readonly string[]> = {
|
|
42
|
+
europe: ESIMMY_EUROPE_CODES,
|
|
43
|
+
schengen: ESIMMY_SCHENGEN_CODES,
|
|
44
|
+
global: ESIMMY_SOLD_COUNTRY_CODES,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type CountryCatalogMode = "plan" | "esimmy";
|
|
48
|
+
|
|
49
|
+
/** Empty and anything other than `esimmy` keep the plan's own coverage. */
|
|
50
|
+
export function countryCatalogMode(raw: string | undefined): CountryCatalogMode {
|
|
51
|
+
return raw?.trim().toLowerCase() === "esimmy" ? "esimmy" : "plan";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const regionNames = new Intl.DisplayNames(["en"], { type: "region" });
|
|
55
|
+
const NAME_TO_CODE = new Map<string, string>();
|
|
56
|
+
|
|
57
|
+
for (const code of ESIMMY_SOLD_COUNTRY_CODES) {
|
|
58
|
+
try {
|
|
59
|
+
const label = regionNames.of(code);
|
|
60
|
+
if (label && label.toUpperCase() !== code) NAME_TO_CODE.set(fold(label), code);
|
|
61
|
+
} catch {
|
|
62
|
+
// A runtime without that region still accepts the code itself.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
NAME_TO_CODE.set("uk", "GB");
|
|
67
|
+
NAME_TO_CODE.set("united kingdom", "GB");
|
|
68
|
+
NAME_TO_CODE.set("czech republic", "CZ");
|
|
69
|
+
NAME_TO_CODE.set("uae", "AE");
|
|
70
|
+
NAME_TO_CODE.set("united arab emirates", "AE");
|
|
71
|
+
NAME_TO_CODE.set("usa", "US");
|
|
72
|
+
NAME_TO_CODE.set("united states", "US");
|
|
73
|
+
NAME_TO_CODE.set("south korea", "KR");
|
|
74
|
+
NAME_TO_CODE.set("korea", "KR");
|
|
75
|
+
NAME_TO_CODE.set("holland", "NL");
|
|
76
|
+
NAME_TO_CODE.set("turkey", "TR");
|
|
77
|
+
|
|
78
|
+
function fold(value: string): string {
|
|
79
|
+
return value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim().toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function addCode(out: string[], seen: Set<string>, code: string): void {
|
|
83
|
+
if (!/^[A-Z]{2}$/.test(code) || code === "ZZ" || seen.has(code)) return;
|
|
84
|
+
seen.add(code);
|
|
85
|
+
out.push(code);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function keepToken(out: string[], seen: Set<string>, token: string): void {
|
|
89
|
+
if (seen.has(token)) return;
|
|
90
|
+
seen.add(token);
|
|
91
|
+
out.push(token);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Turn a plan's coverage into the codes the shop can search.
|
|
96
|
+
*
|
|
97
|
+
* `plan` (the default) keeps the plan's own words. Europe stays Europe.
|
|
98
|
+
* `esimmy` expands Europe, Schengen and Global to that shop's country list.
|
|
99
|
+
* A two-letter code or a country the plan names is kept either way.
|
|
100
|
+
*/
|
|
101
|
+
export function resolvePlanRegions(
|
|
102
|
+
regions: readonly string[],
|
|
103
|
+
mode: CountryCatalogMode = "plan",
|
|
104
|
+
): string[] {
|
|
105
|
+
const out: string[] = [];
|
|
106
|
+
const seen = new Set<string>();
|
|
107
|
+
for (const raw of regions) {
|
|
108
|
+
const trimmed = raw.trim();
|
|
109
|
+
if (!trimmed) continue;
|
|
110
|
+
const upper = trimmed.toUpperCase();
|
|
111
|
+
if (upper === "UK") {
|
|
112
|
+
addCode(out, seen, "GB");
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (/^[A-Z]{2}$/.test(upper)) {
|
|
116
|
+
addCode(out, seen, upper);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const labelled = REGION_LABELS[fold(trimmed)];
|
|
120
|
+
if (labelled) {
|
|
121
|
+
if (mode === "esimmy") {
|
|
122
|
+
for (const code of labelled) addCode(out, seen, code);
|
|
123
|
+
} else {
|
|
124
|
+
keepToken(out, seen, trimmed);
|
|
125
|
+
}
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const named = NAME_TO_CODE.get(fold(trimmed));
|
|
129
|
+
if (named) addCode(out, seen, named);
|
|
130
|
+
else keepToken(out, seen, trimmed);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function withCountryCodes<T extends { regions?: readonly string[] | null }>(
|
|
136
|
+
plans: readonly T[],
|
|
137
|
+
mode: CountryCatalogMode = "plan",
|
|
138
|
+
): T[] {
|
|
139
|
+
return plans.map((plan) => ({
|
|
140
|
+
...plan,
|
|
141
|
+
regions: resolvePlanRegions(Array.isArray(plan.regions) ? plan.regions : [], mode),
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Country search is honest only when every plan is already a list of countries.
|
|
147
|
+
*
|
|
148
|
+
* A region word such as Europe is not a country. In plan mode this template
|
|
149
|
+
* does not invent the eSIMMY list for that word, so search would tell a buyer
|
|
150
|
+
* that Germany is uncovered. Hide search and show the plans instead.
|
|
151
|
+
* `esimmy` has already expanded those words, so search can stay.
|
|
152
|
+
*/
|
|
153
|
+
export function catalogCanSearchCountries(
|
|
154
|
+
plans: readonly { regions?: readonly string[] | null }[],
|
|
155
|
+
mode: CountryCatalogMode,
|
|
156
|
+
): boolean {
|
|
157
|
+
if (mode === "esimmy") return true;
|
|
158
|
+
return plans.every((plan) =>
|
|
159
|
+
(plan.regions ?? []).every((region) => REGION_LABELS[fold(region.trim())] === undefined),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Plans to show for a country query. Region plans are not filtered down to none. */
|
|
164
|
+
export function plansForCountry<T extends { regions?: readonly string[] | null }>(
|
|
165
|
+
plans: readonly T[],
|
|
166
|
+
country: string,
|
|
167
|
+
mode: CountryCatalogMode,
|
|
168
|
+
): { plans: T[]; search: boolean; code: string } {
|
|
169
|
+
const search = catalogCanSearchCountries(plans, mode);
|
|
170
|
+
const code = search && /^[A-Za-z]{2}$/.test(country) ? country.toUpperCase() : "";
|
|
171
|
+
if (!code) return { plans: [...plans], search, code };
|
|
172
|
+
return {
|
|
173
|
+
search,
|
|
174
|
+
code,
|
|
175
|
+
plans: plans.filter((plan) =>
|
|
176
|
+
(plan.regions ?? []).some((region) => region.toUpperCase() === code),
|
|
177
|
+
),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buyer copy for the checkout screen.
|
|
3
|
+
*
|
|
4
|
+
* Browser and JSON parser messages are never shown. A coded refusal may
|
|
5
|
+
* carry its own allow-listed sentence; everything else uses the fallback.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const GENERIC_CHECKOUT_ERROR = "Checkout is unavailable right now. Please try again in a moment.";
|
|
9
|
+
|
|
10
|
+
export class BuyerCopyError extends Error {
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "BuyerCopyError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function checkoutErrorCopy(err: unknown): string {
|
|
18
|
+
if (err instanceof BuyerCopyError && err.message.length > 0) return err.message;
|
|
19
|
+
return GENERIC_CHECKOUT_ERROR;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function readCheckoutJson(res: { json: () => Promise<unknown> }): Promise<unknown> {
|
|
23
|
+
try {
|
|
24
|
+
return await res.json();
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buyer-facing confirmation copy.
|
|
3
|
+
*
|
|
4
|
+
* The order reference is a short tail of the Checkout Session id. The full
|
|
5
|
+
* `cs_` value stays in the activate URL and is never drawn on the page.
|
|
6
|
+
* Claim failures are chosen from this allow-list by HTTP status. The response
|
|
7
|
+
* body is never buyer copy.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const CLAIM_ERROR_FALLBACK = "We could not link this purchase to your account. Your payment is unchanged.";
|
|
11
|
+
|
|
12
|
+
const CLAIM_ERROR_COPY: Record<number, string> = {
|
|
13
|
+
400: CLAIM_ERROR_FALLBACK,
|
|
14
|
+
401: "Sign in again to link this purchase. Your payment is unchanged.",
|
|
15
|
+
403: "This purchase is linked to a different email. Your payment is unchanged.",
|
|
16
|
+
502: "We could not link this purchase right now. Your payment is unchanged.",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const ACCOUNT_STEP_FALLBACK = "We could not finish this step. Your payment is unchanged.";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Clerk error codes the buyer can act on, with copy we own. Only the `code` is
|
|
23
|
+
* read, never Clerk's message text, so an unknown or reworded Clerk error
|
|
24
|
+
* degrades to the generic line instead of leaking provider text.
|
|
25
|
+
*/
|
|
26
|
+
const ACCOUNT_STEP_COPY: Record<string, string> = {
|
|
27
|
+
form_identifier_exists: "An account with this email already exists. Choose Sign in instead.",
|
|
28
|
+
form_password_pwned: "That password has appeared in a data breach. Choose a different password.",
|
|
29
|
+
form_password_length_too_short: "Use a password of at least 8 characters.",
|
|
30
|
+
form_password_not_strong_enough: "That password is too easy to guess. Add length or mix in numbers and symbols.",
|
|
31
|
+
form_password_size_in_bytes_exceeded: "That password is too long. Choose a shorter one.",
|
|
32
|
+
form_code_incorrect: "That code is not right. Check the email and try again.",
|
|
33
|
+
verification_expired: "That code has expired. Go back and create the account again to get a new one.",
|
|
34
|
+
verification_failed: "We could not verify that code. Check the email and try again.",
|
|
35
|
+
form_identifier_not_found: "Email or password is not right. Try again or create an account.",
|
|
36
|
+
form_password_incorrect: "Email or password is not right. Try again or create an account.",
|
|
37
|
+
too_many_requests: "Too many attempts. Wait a minute and try again.",
|
|
38
|
+
session_exists: "You are already signed in. Refresh this page to continue.",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Buyer copy for a failed Clerk sign-up or sign-in step, chosen by error code. */
|
|
42
|
+
export function accountStepError(err: unknown): string {
|
|
43
|
+
const errors = (err as { errors?: unknown } | null | undefined)?.errors;
|
|
44
|
+
if (Array.isArray(errors)) {
|
|
45
|
+
for (const entry of errors) {
|
|
46
|
+
const code = (entry as { code?: unknown } | null)?.code;
|
|
47
|
+
if (typeof code === "string" && Object.prototype.hasOwnProperty.call(ACCOUNT_STEP_COPY, code)) {
|
|
48
|
+
return ACCOUNT_STEP_COPY[code];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return ACCOUNT_STEP_FALLBACK;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function shortOrderReference(orderId: string): string {
|
|
56
|
+
const stripped = orderId.replace(/^cs_(?:test|live)_/i, "");
|
|
57
|
+
const alnum = stripped.replace(/[^A-Za-z0-9]/g, "");
|
|
58
|
+
const tail = alnum.slice(-6).toUpperCase();
|
|
59
|
+
return tail.length > 0 ? tail : "------";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function buyerFacingError(status: number): string {
|
|
63
|
+
return CLAIM_ERROR_COPY[status] ?? CLAIM_ERROR_FALLBACK;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type ConfirmationTitleInput =
|
|
67
|
+
| "mock"
|
|
68
|
+
| { ok: true }
|
|
69
|
+
| { ok: false; status: "processing" | "unpaid" | "unavailable" | "rejected" };
|
|
70
|
+
|
|
71
|
+
/** Document title for a confirmation outcome. Only a settled session is confirmed. */
|
|
72
|
+
export function confirmationDocumentTitle(outcome: ConfirmationTitleInput): string {
|
|
73
|
+
if (outcome === "mock") return "Payment was not completed";
|
|
74
|
+
if (outcome.ok) return "Payment confirmed";
|
|
75
|
+
switch (outcome.status) {
|
|
76
|
+
case "processing":
|
|
77
|
+
return "Payment processing";
|
|
78
|
+
case "unpaid":
|
|
79
|
+
return "Payment did not go through";
|
|
80
|
+
case "unavailable":
|
|
81
|
+
return "We couldn't confirm your payment yet";
|
|
82
|
+
case "rejected":
|
|
83
|
+
return "Payment could not be confirmed";
|
|
84
|
+
default: {
|
|
85
|
+
const unhandled: never = outcome.status;
|
|
86
|
+
return unhandled;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { clerkClient } from "@clerk/nextjs/server";
|
|
2
|
+
import { buyerFacingError } from "@/lib/checkout-copy";
|
|
2
3
|
import { verifyCheckoutSession } from "@/lib/verify-checkout-session";
|
|
3
4
|
|
|
4
5
|
type StripeSession = {
|
|
@@ -156,7 +157,7 @@ export async function claimCheckoutOrder(
|
|
|
156
157
|
): Promise<{ ok: true; orderId: string } | { ok: false; status: number; error: string }> {
|
|
157
158
|
const verification = await verifyCheckoutSession(sessionId, templateId);
|
|
158
159
|
if (!verification.ok) {
|
|
159
|
-
return { ok: false, status: 400, error:
|
|
160
|
+
return { ok: false, status: 400, error: buyerFacingError(400) };
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
const orderId = verification.orderId;
|
|
@@ -164,11 +165,11 @@ export async function claimCheckoutOrder(
|
|
|
164
165
|
if (sessionId !== "mock" && process.env.STRIPE_SECRET_KEY) {
|
|
165
166
|
const session = await fetchStripeSession(sessionId);
|
|
166
167
|
if (!session) {
|
|
167
|
-
return { ok: false, status: 400, error:
|
|
168
|
+
return { ok: false, status: 400, error: buyerFacingError(400) };
|
|
168
169
|
}
|
|
169
170
|
|
|
170
171
|
if (!(await userMatchesSessionPurchaser(session, userId))) {
|
|
171
|
-
return { ok: false, status: 403, error:
|
|
172
|
+
return { ok: false, status: 403, error: buyerFacingError(403) };
|
|
172
173
|
}
|
|
173
174
|
|
|
174
175
|
const claimedBy = session.metadata?.claimedByUserId;
|
|
@@ -191,11 +192,11 @@ export async function claimCheckoutOrder(
|
|
|
191
192
|
*/
|
|
192
193
|
const moved = await setStripeClaimedBy(sessionId, userId);
|
|
193
194
|
if (!moved) {
|
|
194
|
-
return { ok: false, status: 502, error:
|
|
195
|
+
return { ok: false, status: 502, error: buyerFacingError(502) };
|
|
195
196
|
}
|
|
196
197
|
const afterMove = await fetchStripeSession(sessionId);
|
|
197
198
|
if (afterMove?.metadata?.claimedByUserId !== userId) {
|
|
198
|
-
return { ok: false, status: 403, error:
|
|
199
|
+
return { ok: false, status: 403, error: buyerFacingError(403) };
|
|
199
200
|
}
|
|
200
201
|
|
|
201
202
|
// Best-effort tidy-up. The order is already on the right account; a
|
|
@@ -209,12 +210,12 @@ export async function claimCheckoutOrder(
|
|
|
209
210
|
if (!claimedBy) {
|
|
210
211
|
const updated = await setStripeClaimedBy(sessionId, userId);
|
|
211
212
|
if (!updated) {
|
|
212
|
-
return { ok: false, status: 502, error:
|
|
213
|
+
return { ok: false, status: 502, error: buyerFacingError(502) };
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
const afterClaim = await fetchStripeSession(sessionId);
|
|
216
217
|
if (afterClaim?.metadata?.claimedByUserId !== userId) {
|
|
217
|
-
return { ok: false, status: 403, error:
|
|
218
|
+
return { ok: false, status: 403, error: buyerFacingError(403) };
|
|
218
219
|
}
|
|
219
220
|
}
|
|
220
221
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the buyer should see when they land on the return URL.
|
|
3
|
+
*
|
|
4
|
+
* `session_id=mock` is the old no-Stripe shortcut. It is an error: no money
|
|
5
|
+
* moved and no eSIM was issued. A paid Stripe session is a confirmation.
|
|
6
|
+
*/
|
|
7
|
+
export interface CheckoutProblem {
|
|
8
|
+
title: string;
|
|
9
|
+
message: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function mockSessionProblem(sessionId: string | undefined): CheckoutProblem | null {
|
|
13
|
+
if (sessionId !== "mock") return null;
|
|
14
|
+
return {
|
|
15
|
+
title: "Payment was not completed",
|
|
16
|
+
message: "This checkout did not take a payment, and no eSIM was issued.",
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which Clerk instance this storefront talks to.
|
|
3
|
+
*
|
|
4
|
+
* `dev` is the default and the only instance this template configures.
|
|
5
|
+
* `production` is a switch: it reads a different pair of key names and does
|
|
6
|
+
* not create, fetch, or modify a Clerk application. Leave those keys empty
|
|
7
|
+
* until a production instance is approved.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type ClerkInstanceMode = "dev" | "production";
|
|
11
|
+
|
|
12
|
+
export interface ClerkKeyNames {
|
|
13
|
+
publishable: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY" | "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY_PRODUCTION";
|
|
14
|
+
secret: "CLERK_SECRET_KEY" | "CLERK_SECRET_KEY_PRODUCTION";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function blankToUndefined(value: string | undefined): string | undefined {
|
|
18
|
+
const trimmed = value?.trim();
|
|
19
|
+
return trimmed ? trimmed : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function clerkInstanceMode(raw: string | undefined): ClerkInstanceMode {
|
|
23
|
+
return raw?.trim().toLowerCase() === "production" ? "production" : "dev";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function clerkKeyNames(mode: ClerkInstanceMode): ClerkKeyNames {
|
|
27
|
+
switch (mode) {
|
|
28
|
+
case "production":
|
|
29
|
+
return {
|
|
30
|
+
publishable: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY_PRODUCTION",
|
|
31
|
+
secret: "CLERK_SECRET_KEY_PRODUCTION",
|
|
32
|
+
};
|
|
33
|
+
case "dev":
|
|
34
|
+
return {
|
|
35
|
+
publishable: "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY",
|
|
36
|
+
secret: "CLERK_SECRET_KEY",
|
|
37
|
+
};
|
|
38
|
+
default: {
|
|
39
|
+
const neverMode: never = mode;
|
|
40
|
+
return neverMode;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function clerkKeyFrom(
|
|
46
|
+
mode: ClerkInstanceMode,
|
|
47
|
+
keys: { dev?: string; production?: string },
|
|
48
|
+
): string | undefined {
|
|
49
|
+
switch (mode) {
|
|
50
|
+
case "production":
|
|
51
|
+
return blankToUndefined(keys.production);
|
|
52
|
+
case "dev":
|
|
53
|
+
return blankToUndefined(keys.dev);
|
|
54
|
+
default: {
|
|
55
|
+
const neverMode: never = mode;
|
|
56
|
+
return neverMode;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Publishable key for the active instance. Static env reads so Next can inline them. */
|
|
62
|
+
export function clerkPublishableKey(): string | undefined {
|
|
63
|
+
return clerkKeyFrom(clerkInstanceMode(process.env.NEXT_PUBLIC_CLERK_INSTANCE), {
|
|
64
|
+
dev: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
|
65
|
+
production: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY_PRODUCTION,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Secret key for the active instance. Server only. */
|
|
70
|
+
export function clerkSecretKey(): string | undefined {
|
|
71
|
+
return clerkKeyFrom(clerkInstanceMode(process.env.NEXT_PUBLIC_CLERK_INSTANCE), {
|
|
72
|
+
dev: process.env.CLERK_SECRET_KEY,
|
|
73
|
+
production: process.env.CLERK_SECRET_KEY_PRODUCTION,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Point Clerk's own env names at the production pair when the switch is on.
|
|
79
|
+
*
|
|
80
|
+
* @clerk/nextjs reads `CLERK_SECRET_KEY` itself. Dev mode leaves both names
|
|
81
|
+
* untouched, so a production key sitting in the environment is not used.
|
|
82
|
+
*/
|
|
83
|
+
export function applyClerkInstanceEnv(): void {
|
|
84
|
+
if (clerkInstanceMode(process.env.NEXT_PUBLIC_CLERK_INSTANCE) !== "production") return;
|
|
85
|
+
const publishable = blankToUndefined(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY_PRODUCTION);
|
|
86
|
+
const secret = blankToUndefined(process.env.CLERK_SECRET_KEY_PRODUCTION);
|
|
87
|
+
if (publishable) process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY = publishable;
|
|
88
|
+
if (secret) process.env.CLERK_SECRET_KEY = secret;
|
|
89
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A short label a buyer can quote.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard must not print a full checkout session id (`cs_…`).
|
|
5
|
+
*/
|
|
6
|
+
export function shortOrderReference(orderId: string): string {
|
|
7
|
+
const body = orderId.replace(/^cs_(?:test_|live_)?/i, "");
|
|
8
|
+
const tail = body.replace(/[^a-zA-Z0-9]/g, "").slice(-4).toUpperCase();
|
|
9
|
+
return tail.length > 0 ? `Order ${tail}` : "Order";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The full checkout id stays in the subject. The link text does not include it. */
|
|
13
|
+
export function supportOrderHref(email: string, orderId: string): string {
|
|
14
|
+
return `mailto:${email}?subject=${encodeURIComponent(`Order ${orderId}`)}`;
|
|
15
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Origin used to keep a sign-in return path on this shop.
|
|
3
|
+
*
|
|
4
|
+
* Built from the request, not from the browser. `NEXT_PUBLIC_APP_URL` wins
|
|
5
|
+
* when it is set. Otherwise the forwarded host on this request is used.
|
|
6
|
+
*/
|
|
7
|
+
export function requestOrigin(headerStore: { get(name: string): string | null }): string {
|
|
8
|
+
const configured = process.env.NEXT_PUBLIC_APP_URL?.trim();
|
|
9
|
+
if (configured) {
|
|
10
|
+
try {
|
|
11
|
+
return new URL(configured).origin;
|
|
12
|
+
} catch {
|
|
13
|
+
// A bad configured URL falls through to the request host.
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const host = firstHeader(headerStore.get("x-forwarded-host") ?? headerStore.get("host"));
|
|
17
|
+
if (!host) return "";
|
|
18
|
+
const proto = firstHeader(headerStore.get("x-forwarded-proto")) || "https";
|
|
19
|
+
try {
|
|
20
|
+
return new URL(`${proto}://${host}`).origin;
|
|
21
|
+
} catch {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function firstHeader(value: string | null): string {
|
|
27
|
+
return value?.split(",")[0]?.trim() ?? "";
|
|
28
|
+
}
|
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
const ALLOWED_PATHS = /^\/[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]*$/;
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Keep a buyer on the path they asked for.
|
|
5
|
+
*
|
|
6
|
+
* A path stays a path. Clerk sometimes sends a same-origin absolute URL;
|
|
7
|
+
* `new URL` accepts that only when the origin matches. Anything else,
|
|
8
|
+
* including another host, falls back.
|
|
9
|
+
*/
|
|
3
10
|
export function sanitizeAuthRedirect(
|
|
4
11
|
rawRedirect: string | null | undefined,
|
|
5
12
|
fallback = "/dashboard",
|
|
13
|
+
origin?: string,
|
|
6
14
|
): string {
|
|
7
15
|
if (!rawRedirect) return fallback;
|
|
16
|
+
const raw = rawRedirect.trim();
|
|
17
|
+
if (!raw || raw.startsWith("//")) return fallback;
|
|
8
18
|
try {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
19
|
+
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(raw)) {
|
|
20
|
+
if (!origin) return fallback;
|
|
21
|
+
const url = new URL(raw);
|
|
22
|
+
if (url.origin !== new URL(origin).origin) return fallback;
|
|
23
|
+
const path = `${url.pathname}${url.search}${url.hash}`;
|
|
24
|
+
if (!path.startsWith("/") || path.startsWith("//")) return fallback;
|
|
25
|
+
return path;
|
|
26
|
+
}
|
|
27
|
+
if (!ALLOWED_PATHS.test(raw)) return fallback;
|
|
28
|
+
return raw;
|
|
13
29
|
} catch {
|
|
14
30
|
return fallback;
|
|
15
31
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 16px type so a phone does not zoom the page on focus.
|
|
3
|
+
* 44px tall so a thumb can hit the control.
|
|
4
|
+
*/
|
|
5
|
+
export const fieldClass =
|
|
6
|
+
"w-full min-h-11 rounded-lg border border-sand-200 bg-sand-50 px-3 text-base text-sand-900 placeholder:text-sand-400 focus:border-[var(--brand-accent-text)] dark:border-sand-800 dark:bg-sand-950 dark:text-sand-50";
|
|
7
|
+
|
|
8
|
+
/** Filled #FF6B35. The label is #111111, never white. */
|
|
9
|
+
export const primaryButtonClass =
|
|
10
|
+
"inline-flex min-h-11 w-full items-center justify-center rounded-lg bg-[var(--brand-accent)] px-4 text-base font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90 disabled:opacity-50";
|
|
11
|
+
|
|
12
|
+
/** Accent text and links use #C2410C on light surfaces. */
|
|
13
|
+
export const textLinkClass =
|
|
14
|
+
"inline-flex min-h-11 items-center text-base font-semibold text-[var(--brand-accent-text)] hover:underline";
|
|
15
|
+
|
|
16
|
+
export const filledAccentClass =
|
|
17
|
+
"inline-flex min-h-11 items-center justify-center rounded-full bg-[var(--brand-accent)] px-4 text-base font-semibold text-[var(--brand-on-accent)] transition-opacity hover:opacity-90";
|