@carrierllc/mcp 0.13.41 → 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.
Files changed (68) hide show
  1. package/dist/{chunk-6OAZJBLA.js → chunk-4XUAUQ3U.js} +67 -7
  2. package/dist/chunk-4XUAUQ3U.js.map +1 -0
  3. package/dist/cli.js +55 -9
  4. package/dist/cli.js.map +1 -1
  5. package/dist/index.js +66 -15
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/templates/storefront/CLERK.md +23 -0
  9. package/templates/storefront/src/app/activate/[orderId]/ActivateClient.tsx +29 -22
  10. package/templates/storefront/src/app/activate/[orderId]/page.tsx +2 -2
  11. package/templates/storefront/src/app/api/checkout/claim/route.ts +7 -6
  12. package/templates/storefront/src/app/api/checkout/guest/route.ts +72 -17
  13. package/templates/storefront/src/app/api/orders/[orderId]/route.ts +2 -2
  14. package/templates/storefront/src/app/checkout/[templateId]/CheckoutClient.tsx +51 -25
  15. package/templates/storefront/src/app/checkout/success/CheckoutSuccessClient.tsx +165 -131
  16. package/templates/storefront/src/app/checkout/success/FocusHeading.tsx +20 -0
  17. package/templates/storefront/src/app/checkout/success/page.tsx +136 -15
  18. package/templates/storefront/src/app/dashboard/EsimControls.tsx +20 -24
  19. package/templates/storefront/src/app/dashboard/page.tsx +43 -23
  20. package/templates/storefront/src/app/globals.css +44 -5
  21. package/templates/storefront/src/app/help/page.tsx +2 -2
  22. package/templates/storefront/src/app/layout.tsx +5 -3
  23. package/templates/storefront/src/app/page.tsx +27 -7
  24. package/templates/storefront/src/app/shop/ShopClient.tsx +106 -72
  25. package/templates/storefront/src/app/shop/page.tsx +7 -2
  26. package/templates/storefront/src/app/sign-in/[[...sign-in]]/StorefrontSignInClient.tsx +297 -0
  27. package/templates/storefront/src/app/sign-in/[[...sign-in]]/page.tsx +13 -14
  28. package/templates/storefront/src/app/sign-up/[[...sign-up]]/StorefrontSignUpClient.tsx +31 -28
  29. package/templates/storefront/src/app/sign-up/[[...sign-up]]/page.tsx +5 -2
  30. package/templates/storefront/src/brand.config.ts +16 -0
  31. package/templates/storefront/src/components/Providers.tsx +20 -1
  32. package/templates/storefront/src/components/faq/FaqSection.tsx +2 -2
  33. package/templates/storefront/src/components/footer/StorefrontFooter.tsx +7 -7
  34. package/templates/storefront/src/components/landing/FinalCTA.tsx +4 -9
  35. package/templates/storefront/src/components/landing/HeroSection.tsx +4 -7
  36. package/templates/storefront/src/components/landing/HowItWorks.tsx +1 -1
  37. package/templates/storefront/src/components/landing/PlanCard.tsx +23 -13
  38. package/templates/storefront/src/components/landing/TierPlanBoard.tsx +237 -0
  39. package/templates/storefront/src/components/nav/StorefrontNav.tsx +8 -11
  40. package/templates/storefront/src/components/shop/CountrySearch.tsx +94 -0
  41. package/templates/storefront/src/components/theme/BrandStyles.tsx +2 -2
  42. package/templates/storefront/src/components/theme/ThemeToggle.tsx +1 -1
  43. package/templates/storefront/src/components/theme/brand-accent-css.ts +21 -0
  44. package/templates/storefront/src/components/theme/brand-style.ts +88 -0
  45. package/templates/storefront/src/components/theme/clerk-appearance.ts +18 -2
  46. package/templates/storefront/src/lib/buyer-auth-copy.ts +83 -0
  47. package/templates/storefront/src/lib/buyer-error.ts +32 -0
  48. package/templates/storefront/src/lib/buyer-session.ts +41 -0
  49. package/templates/storefront/src/lib/catalog-countries.ts +179 -0
  50. package/templates/storefront/src/lib/checkout-client-error.ts +28 -0
  51. package/templates/storefront/src/lib/checkout-copy.ts +89 -0
  52. package/templates/storefront/src/lib/checkout-order-claim.ts +8 -7
  53. package/templates/storefront/src/lib/checkout-return.ts +18 -0
  54. package/templates/storefront/src/lib/clerk-instance.ts +89 -0
  55. package/templates/storefront/src/lib/order-reference.ts +15 -0
  56. package/templates/storefront/src/lib/request-origin.ts +28 -0
  57. package/templates/storefront/src/lib/sanitize-auth-redirect.ts +20 -4
  58. package/templates/storefront/src/lib/tap-target.ts +17 -0
  59. package/templates/storefront/src/lib/verify-checkout-session.ts +118 -19
  60. package/templates/storefront/src/middleware.ts +11 -0
  61. package/templates/storefront/src/vendor/carrier/catalog-guard.ts +9 -5
  62. package/templates/storefront/src/vendor/carrier/client.ts +22 -4
  63. package/templates/storefront/src/vendor/carrier/country-offers.ts +106 -0
  64. package/templates/storefront/src/vendor/carrier/stripe-price.ts +134 -0
  65. package/templates/storefront/src/vendor/carrier/tiers.ts +161 -0
  66. package/templates/storefront/src/vendor/carrier/types.ts +22 -0
  67. package/templates/storefront/src/vendor/geo/index.ts +15 -4
  68. package/dist/chunk-6OAZJBLA.js.map +0 -1
@@ -2,124 +2,141 @@
2
2
  /**
3
3
  * ShopClient — pick a plan, see what it costs, pay.
4
4
  *
5
- * Selecting a card used to navigate straight to /checkout, so the shopper was
6
- * handed to Stripe without ever seeing the total they were about to be charged.
7
- * The order summary is that missing beat: name, price, what is included, total.
8
- *
9
- * On a wide screen the summary is a sticky column and is always in view. On a
10
- * phone it is stacked underneath the plans, which is where the real defect was:
11
- * tapping a card changed something the customer could not see, so the tap read
12
- * as dead. `scrollSummaryIntoView` answers the tap by bringing the summary —
13
- * and the pay button in it — on screen.
5
+ * The tier radio is what selects a plan, including a tier that has only one
6
+ * data and validity option. Changing data or validity updates the price of
7
+ * the tier that is already selected and does not scroll the page. On a phone
8
+ * the pay button stays fixed to the bottom of the screen.
14
9
  */
15
10
 
16
- import { useCallback, useRef, useState } from "react";
11
+ import { useState } from "react";
17
12
  import { useRouter } from "next/navigation";
13
+ import Link from "next/link";
18
14
  import { PlanCard } from "@/components/landing/PlanCard";
15
+ import { TierPlanBoard } from "@/components/landing/TierPlanBoard";
16
+ import brand from "@/brand.config";
17
+ import { CountrySearch } from "@/components/shop/CountrySearch";
19
18
  import { conversionEvents } from "@/lib/conversion-events";
19
+ import { countryCatalogMode, plansForCountry } from "@/lib/catalog-countries";
20
+ import { filledAccentClass, textLinkClass } from "@/lib/tap-target";
21
+ import { presentTierCatalog, speedTierOf } from "@/vendor/carrier/tiers";
20
22
  import { formatDataAllowance, formatPrice, formatValidity } from "@/vendor/geo";
21
23
  import type { SkuTemplate } from "@/vendor/carrier/types";
22
24
 
23
- /** Above this width the summary is a sticky column and never leaves the fold. */
24
- const WIDE_VIEWPORT = "(min-width: 1024px)";
25
-
26
- function prefersReducedMotion(): boolean {
27
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
28
- return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
29
- }
30
-
31
- function isWideViewport(): boolean {
32
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
33
- return window.matchMedia(WIDE_VIEWPORT).matches;
34
- }
25
+ const secondary = "text-sand-700 dark:text-sand-200";
35
26
 
36
- export function ShopClient({ plans }: { plans: SkuTemplate[] }) {
27
+ export function ShopClient({ plans, country = "" }: { plans: SkuTemplate[]; country?: string }) {
28
+ const coverage = plansForCountry(
29
+ plans,
30
+ country,
31
+ countryCatalogMode(process.env.NEXT_PUBLIC_COUNTRY_CATALOG),
32
+ );
33
+ const visible = coverage.plans;
34
+ const code = coverage.code;
37
35
  const router = useRouter();
38
36
  const [selected, setSelected] = useState<SkuTemplate | null>(null);
39
- const summaryRef = useRef<HTMLElement | null>(null);
40
-
41
- /**
42
- * Bring the summary on screen after a selection, on narrow viewports only.
43
- *
44
- * `scrollIntoView` is missing in some engines and in jsdom, so the call is
45
- * optional-chained: a browser that cannot scroll must still select the plan.
46
- */
47
- const scrollSummaryIntoView = useCallback(() => {
48
- if (isWideViewport()) return;
49
- requestAnimationFrame(() => {
50
- summaryRef.current?.scrollIntoView?.({
51
- behavior: prefersReducedMotion() ? "auto" : "smooth",
52
- block: "start",
53
- });
54
- });
55
- }, []);
37
+ const lightningEnabled = brand.lightningTier;
56
38
 
57
39
  function handleSelect(plan: SkuTemplate) {
58
40
  conversionEvents.selectPlan(plan.name);
59
41
  setSelected(plan);
60
- scrollSummaryIntoView();
42
+ }
43
+
44
+ function handleAdjust(plan: SkuTemplate) {
45
+ setSelected((current) => {
46
+ if (!current || speedTierOf(current) !== speedTierOf(plan)) return current;
47
+ return plan;
48
+ });
61
49
  }
62
50
 
63
51
  function handleContinue() {
64
- if (!selected) return;
52
+ if (!selected || selected.price_unavailable) return;
65
53
  router.push(`/checkout/${selected.id}`);
66
54
  }
67
55
 
68
- const total = selected ? formatPrice(selected.price_cents, selected.currency) : null;
56
+ const view = presentTierCatalog(visible, { lightningEnabled });
57
+ const total = !selected
58
+ ? null
59
+ : selected.price_unavailable
60
+ ? "Unavailable"
61
+ : formatPrice(selected.price_cents, selected.currency);
69
62
 
70
63
  return (
71
- <div className="mx-auto max-w-6xl px-4 py-16">
64
+ <div className="mx-auto max-w-6xl px-4 py-16 pb-28 lg:pb-16">
72
65
  <h1 className="text-4xl font-semibold tracking-tight text-sand-900 dark:text-sand-50">
73
66
  Choose your plan
74
67
  </h1>
75
- <p className="mt-2 mb-12 text-sand-600 dark:text-sand-400">
68
+ <p className={`mt-2 mb-12 ${secondary}`}>
76
69
  Instant activation. No contracts. Price next to the limit.
77
70
  </p>
78
71
 
72
+ {coverage.search && (
73
+ <div className="-mx-4 mb-12">
74
+ <CountrySearch plans={plans} initialQuery={code} />
75
+ </div>
76
+ )}
77
+
79
78
  <div className="grid grid-cols-1 items-start gap-8 lg:grid-cols-[minmax(0,1fr)_320px]">
80
- <div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
81
- {plans.map((plan, i) => (
82
- <PlanCard
83
- key={plan.id}
84
- plan={plan}
85
- featured={i === 1}
86
- selected={selected?.id === plan.id}
87
- onSelect={handleSelect}
88
- />
89
- ))}
79
+ <div className="grid grid-cols-1 gap-8">
80
+ {coverage.search && visible.length === 0 && (
81
+ <p className="text-base text-sand-700 dark:text-sand-200">
82
+ No plan covers that country yet.{" "}
83
+ <Link href="/shop" className={textLinkClass}>
84
+ Browse plans
85
+ </Link>
86
+ </p>
87
+ )}
88
+ <TierPlanBoard
89
+ plans={visible}
90
+ mode="select"
91
+ lightningEnabled={lightningEnabled}
92
+ selectedId={selected?.id ?? null}
93
+ onSelect={handleSelect}
94
+ onAdjust={handleAdjust}
95
+ />
96
+ {view.untiered.length > 0 ? (
97
+ <div className="grid grid-cols-1 gap-8 sm:grid-cols-2">
98
+ {view.untiered.map((plan) => (
99
+ <PlanCard
100
+ key={plan.id}
101
+ plan={plan}
102
+ selected={selected?.id === plan.id}
103
+ onSelect={handleSelect}
104
+ />
105
+ ))}
106
+ </div>
107
+ ) : null}
90
108
  </div>
91
109
 
92
110
  <section
93
- ref={summaryRef}
94
111
  aria-label="Order summary"
95
112
  aria-live="polite"
96
- className="scroll-mt-24 rounded-2xl border border-sand-200 bg-white p-6 lg:sticky lg:top-24 dark:border-sand-800 dark:bg-sand-900"
113
+ className="rounded-2xl border border-sand-200 bg-white p-6 lg:sticky lg:top-24 dark:border-sand-700 dark:bg-sand-900"
97
114
  >
98
- <p className="text-xs font-semibold tracking-widest text-sand-500 uppercase">
115
+ <p className={`text-xs font-semibold tracking-widest uppercase ${secondary}`}>
99
116
  Your order
100
117
  </p>
101
118
  <h2 className="mt-3 text-xl font-semibold tracking-tight text-sand-900 dark:text-sand-50">
102
119
  {selected?.name ?? "Pick a plan"}
103
120
  </h2>
104
- <p className="mt-1 text-sm text-sand-500">
121
+ <p className={`mt-1 text-sm ${secondary}`}>
105
122
  {selected
106
123
  ? `${formatDataAllowance(selected.dataGb, selected.unlimited)} · ${formatValidity(selected.validityDays)}`
107
- : "Choose a plan on the left and the total appears here."}
124
+ : "Choose a speed tier and the total appears here."}
108
125
  </p>
109
126
 
110
- <dl className="my-5 grid gap-3 border-y border-sand-200 py-5 text-sm dark:border-sand-800">
127
+ <dl className="my-5 grid gap-3 border-y border-sand-200 py-5 text-sm dark:border-sand-700">
111
128
  <div className="flex items-baseline justify-between gap-3">
112
- <dt className="text-sand-500">Plan</dt>
129
+ <dt className={secondary}>Plan</dt>
113
130
  <dd className="font-mono tabular-nums text-sand-900 dark:text-sand-50">
114
131
  {total ?? "—"}
115
132
  </dd>
116
133
  </div>
117
134
  <div className="flex items-baseline justify-between gap-3">
118
- <dt className="text-sand-500">eSIM activation</dt>
119
- <dd className="font-mono tabular-nums text-leaf-600">Free</dd>
135
+ <dt className={secondary}>eSIM activation</dt>
136
+ <dd className="font-mono tabular-nums text-[#166534] dark:text-[#BBF7D0]">Free</dd>
120
137
  </div>
121
138
  <div className="flex items-baseline justify-between gap-3">
122
- <dt className="text-sand-500">Tax</dt>
139
+ <dt className={secondary}>Tax</dt>
123
140
  <dd className="font-mono tabular-nums text-sand-900 dark:text-sand-50">Included</dd>
124
141
  </div>
125
142
  </dl>
@@ -136,15 +153,32 @@ export function ShopClient({ plans }: { plans: SkuTemplate[] }) {
136
153
  <button
137
154
  type="button"
138
155
  onClick={handleContinue}
139
- disabled={!selected}
140
- className="mt-6 w-full rounded-full px-6 py-3 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
141
- style={{ background: "var(--brand-accent)" }}
156
+ disabled={!selected || selected.price_unavailable === true}
157
+ className={`${filledAccentClass} fixed inset-x-4 bottom-4 z-30 disabled:cursor-not-allowed disabled:bg-sand-200 disabled:text-sand-700 lg:static lg:inset-auto lg:z-auto lg:mt-6 lg:w-full`}
142
158
  >
143
- {selected ? "Continue to payment" : "Select a plan first"}
159
+ {selected?.price_unavailable
160
+ ? "Price unavailable"
161
+ : selected
162
+ ? "Continue to payment"
163
+ : "Select a plan first"}
144
164
  </button>
145
- <p className="mt-3 text-xs leading-relaxed text-sand-400">
146
- Your QR code is ready the moment the payment clears. Install it before you fly.
147
- </p>
165
+ {selected?.price_unavailable ? (
166
+ <>
167
+ <p role="status" className={`mt-3 text-sm leading-relaxed ${secondary}`}>
168
+ {"We can't show a price for this plan right now, so it can't be bought yet. Pick another plan, or try again later."}
169
+ </p>
170
+ <a
171
+ href={brand.supportUrl}
172
+ className={`mt-3 inline-flex min-h-11 items-center text-base underline ${secondary}`}
173
+ >
174
+ Contact support
175
+ </a>
176
+ </>
177
+ ) : (
178
+ <p className={`mt-3 text-sm leading-relaxed ${secondary}`}>
179
+ Next, you pay on the secure checkout page. Your QR code is ready once the payment clears.
180
+ </p>
181
+ )}
148
182
  </section>
149
183
  </div>
150
184
  </div>
@@ -3,7 +3,12 @@ import { ShopClient } from "./ShopClient";
3
3
 
4
4
  export const metadata = { title: "Shop" };
5
5
 
6
- export default async function ShopPage() {
6
+ export default async function ShopPage({
7
+ searchParams,
8
+ }: {
9
+ searchParams: Promise<{ country?: string }>;
10
+ }) {
11
+ const { country } = await searchParams;
7
12
  const plans = await fetchTemplates();
8
- return <ShopClient plans={plans} />;
13
+ return <ShopClient plans={plans} country={country ?? ""} />;
9
14
  }
@@ -0,0 +1,297 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { FormEvent, useEffect, useRef, useState } from "react";
5
+ import { useClerk, useSignIn } from "@clerk/nextjs";
6
+ import { useRouter, useSearchParams } from "next/navigation";
7
+ import brand from "@/brand.config";
8
+ import { BUYER_AUTH_COPY, buyerCopyForClerkError, resetEmailOutcome, SIGN_IN_CLERK_CODES, signInStatusCopy } from "@/lib/buyer-auth-copy";
9
+ import { sanitizeAuthRedirect } from "@/lib/sanitize-auth-redirect";
10
+ import { fieldClass, primaryButtonClass, textLinkClass } from "@/lib/tap-target";
11
+
12
+ type Step = "credentials" | "reset-email" | "reset-password";
13
+
14
+ const ERROR_ID = "si-error";
15
+
16
+ export function StorefrontSignInClient({ origin }: { origin: string }) {
17
+ const searchParams = useSearchParams();
18
+ const redirectTarget = sanitizeAuthRedirect(searchParams.get("redirect_url") ?? undefined, "/dashboard", origin || undefined);
19
+ const resetEnabled = brand.passwordResetEnabled;
20
+ const startOnReset = resetEnabled && searchParams.get("reset") === "1";
21
+
22
+ const { signIn, isLoaded } = useSignIn();
23
+ const { setActive } = useClerk();
24
+ const router = useRouter();
25
+
26
+ const [step, setStep] = useState<Step>(startOnReset ? "reset-email" : "credentials");
27
+ const [email, setEmail] = useState("");
28
+ const [password, setPassword] = useState("");
29
+ const [code, setCode] = useState("");
30
+ const [nextPassword, setNextPassword] = useState("");
31
+ const [error, setError] = useState<string | null>(null);
32
+ const [notice, setNotice] = useState<string | null>(null);
33
+ const [loading, setLoading] = useState(false);
34
+ const headingRef = useRef<HTMLHeadingElement>(null);
35
+ const skipFocus = useRef(true);
36
+
37
+ useEffect(() => {
38
+ if (skipFocus.current) {
39
+ skipFocus.current = false;
40
+ return;
41
+ }
42
+ headingRef.current?.focus();
43
+ }, [step]);
44
+
45
+ const invalid = error ? true : undefined;
46
+
47
+ async function finish(sessionId: string | null | undefined) {
48
+ if (!sessionId) {
49
+ setError("Sign-in did not finish. Try again.");
50
+ return;
51
+ }
52
+ await setActive({ session: sessionId });
53
+ router.replace(redirectTarget);
54
+ }
55
+
56
+ async function handleCredentials(event: FormEvent<HTMLFormElement>) {
57
+ event.preventDefault();
58
+ if (!isLoaded || !signIn) return;
59
+ setError(null);
60
+ setLoading(true);
61
+ try {
62
+ const result = await signIn.create({ identifier: email, password });
63
+ if (signInStatusCopy(result.status) === "complete") {
64
+ await finish(result.createdSessionId);
65
+ return;
66
+ }
67
+ setError(BUYER_AUTH_COPY.secondFactor);
68
+ } catch (err) {
69
+ setError(buyerCopyForClerkError(err, BUYER_AUTH_COPY.rejected, SIGN_IN_CLERK_CODES));
70
+ } finally {
71
+ setLoading(false);
72
+ }
73
+ }
74
+
75
+ async function handleResetEmail(event: FormEvent<HTMLFormElement>) {
76
+ event.preventDefault();
77
+ if (!isLoaded || !signIn || !resetEnabled) return;
78
+ setError(null);
79
+ setLoading(true);
80
+ try {
81
+ await signIn.create({ strategy: "reset_password_email_code", identifier: email });
82
+ } catch {
83
+ // The next screen is the same whether or not the account exists.
84
+ } finally {
85
+ const outcome = resetEmailOutcome();
86
+ setNotice(outcome.message);
87
+ setStep(outcome.step);
88
+ setLoading(false);
89
+ }
90
+ }
91
+
92
+ async function handleResetPassword(event: FormEvent<HTMLFormElement>) {
93
+ event.preventDefault();
94
+ if (!isLoaded || !signIn || !resetEnabled) return;
95
+ setError(null);
96
+ setLoading(true);
97
+ try {
98
+ const attempt = await signIn.attemptFirstFactor({ strategy: "reset_password_email_code", code });
99
+ if (attempt.status === "needs_new_password") {
100
+ const reset = await signIn.resetPassword({ password: nextPassword });
101
+ if (signInStatusCopy(reset.status) === "complete") {
102
+ await finish(reset.createdSessionId);
103
+ return;
104
+ }
105
+ }
106
+ if (signInStatusCopy(attempt.status) === "complete") {
107
+ await finish(attempt.createdSessionId);
108
+ return;
109
+ }
110
+ setError(BUYER_AUTH_COPY.resetRejected);
111
+ } catch (err) {
112
+ setError(buyerCopyForClerkError(err, BUYER_AUTH_COPY.resetRejected, ["form_code_incorrect", "too_many_requests"]));
113
+ } finally {
114
+ setLoading(false);
115
+ }
116
+ }
117
+
118
+ return (
119
+ <div className="w-full max-w-sm rounded-2xl border border-sand-200 bg-white p-8 dark:border-sand-800 dark:bg-sand-900">
120
+ <h1
121
+ ref={headingRef}
122
+ tabIndex={-1}
123
+ className="mb-1 text-xl font-bold text-sand-900 dark:text-sand-50"
124
+ >
125
+ {step === "credentials" && <>Sign in to {brand.name}</>}
126
+ {step === "reset-email" && "Reset your password"}
127
+ {step === "reset-password" && "Choose a new password"}
128
+ </h1>
129
+ <p
130
+ id={ERROR_ID}
131
+ role="alert"
132
+ aria-live="assertive"
133
+ className={error ? "mb-4 rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-base text-red-700 dark:text-red-300" : "sr-only"}
134
+ >
135
+ {error}
136
+ </p>
137
+ <p
138
+ role="status"
139
+ aria-live="polite"
140
+ className={notice ? "mb-4 text-base text-sand-700 dark:text-sand-200" : "sr-only"}
141
+ >
142
+ {notice}
143
+ </p>
144
+
145
+ {step === "credentials" && (
146
+ <>
147
+ <p className="mb-6 text-base text-sand-500">Email and password.</p>
148
+ <form onSubmit={handleCredentials} className="space-y-4">
149
+ <div>
150
+ <label className="mb-1 block text-base font-medium text-sand-600 dark:text-sand-400" htmlFor="si-email">
151
+ Email address
152
+ </label>
153
+ <input
154
+ id="si-email"
155
+ type="email"
156
+ autoComplete="email"
157
+ required
158
+ value={email}
159
+ onChange={(event) => setEmail(event.target.value)}
160
+ className={fieldClass}
161
+ placeholder="you@example.com"
162
+ aria-invalid={invalid}
163
+ aria-describedby={ERROR_ID}
164
+ />
165
+ </div>
166
+ <div>
167
+ <label className="mb-1 block text-base font-medium text-sand-600 dark:text-sand-400" htmlFor="si-password">
168
+ Password
169
+ </label>
170
+ <input
171
+ id="si-password"
172
+ type="password"
173
+ autoComplete="current-password"
174
+ required
175
+ value={password}
176
+ onChange={(event) => setPassword(event.target.value)}
177
+ className={fieldClass}
178
+ placeholder="Your password"
179
+ aria-invalid={invalid}
180
+ aria-describedby={ERROR_ID}
181
+ />
182
+ </div>
183
+ <button type="submit" disabled={loading || !isLoaded} className={primaryButtonClass}>
184
+ {loading ? "Signing in…" : "Continue"}
185
+ </button>
186
+ </form>
187
+ {resetEnabled && (
188
+ <button
189
+ type="button"
190
+ onClick={() => {
191
+ setStep("reset-email");
192
+ setError(null);
193
+ setNotice(null);
194
+ }}
195
+ className={`${textLinkClass} mt-2`}
196
+ >
197
+ Forgot password
198
+ </button>
199
+ )}
200
+ <p className="mt-2 text-base text-sand-500">
201
+ New here?{" "}
202
+ <Link href={`/sign-up?redirect_url=${encodeURIComponent(redirectTarget)}`} className={textLinkClass}>
203
+ Create account
204
+ </Link>
205
+ </p>
206
+ </>
207
+ )}
208
+
209
+ {step === "reset-email" && resetEnabled && (
210
+ <>
211
+ <p className="mb-6 text-base text-sand-500">We email you a code. No one at the shop resets it for you.</p>
212
+ <form onSubmit={handleResetEmail} className="space-y-4">
213
+ <div>
214
+ <label className="mb-1 block text-base font-medium text-sand-600 dark:text-sand-400" htmlFor="reset-email">
215
+ Email address
216
+ </label>
217
+ <input
218
+ id="reset-email"
219
+ type="email"
220
+ autoComplete="email"
221
+ required
222
+ value={email}
223
+ onChange={(event) => setEmail(event.target.value)}
224
+ className={fieldClass}
225
+ placeholder="you@example.com"
226
+ aria-invalid={invalid}
227
+ aria-describedby={ERROR_ID}
228
+ />
229
+ </div>
230
+ <button type="submit" disabled={loading || !isLoaded} className={primaryButtonClass}>
231
+ {loading ? "Sending code…" : "Email me a reset code"}
232
+ </button>
233
+ </form>
234
+ <button
235
+ type="button"
236
+ onClick={() => {
237
+ setStep("credentials");
238
+ setError(null);
239
+ setNotice(null);
240
+ }}
241
+ className={`${textLinkClass} mt-2`}
242
+ >
243
+ Back to sign in
244
+ </button>
245
+ </>
246
+ )}
247
+
248
+ {step === "reset-password" && resetEnabled && (
249
+ <>
250
+ <form onSubmit={handleResetPassword} className="space-y-4">
251
+ <div>
252
+ <label className="mb-1 block text-base font-medium text-sand-600 dark:text-sand-400" htmlFor="reset-code">
253
+ Reset code
254
+ </label>
255
+ <input
256
+ id="reset-code"
257
+ type="text"
258
+ inputMode="numeric"
259
+ autoComplete="one-time-code"
260
+ required
261
+ value={code}
262
+ onChange={(event) => setCode(event.target.value.replace(/\D/g, ""))}
263
+ className={fieldClass}
264
+ placeholder="000000"
265
+ aria-invalid={invalid}
266
+ aria-describedby={ERROR_ID}
267
+ />
268
+ </div>
269
+ <div>
270
+ <label className="mb-1 block text-base font-medium text-sand-600 dark:text-sand-400" htmlFor="reset-password">
271
+ New password
272
+ </label>
273
+ <input
274
+ id="reset-password"
275
+ type="password"
276
+ autoComplete="new-password"
277
+ required
278
+ minLength={8}
279
+ value={nextPassword}
280
+ onChange={(event) => setNextPassword(event.target.value)}
281
+ className={fieldClass}
282
+ placeholder="Min. 8 characters"
283
+ aria-invalid={invalid}
284
+ aria-describedby={ERROR_ID}
285
+ />
286
+ </div>
287
+ <button type="submit" disabled={loading || !isLoaded} className={primaryButtonClass}>
288
+ {loading ? "Saving…" : "Save new password"}
289
+ </button>
290
+ </form>
291
+ </>
292
+ )}
293
+
294
+ <div id="clerk-captcha" />
295
+ </div>
296
+ );
297
+ }
@@ -1,24 +1,23 @@
1
1
  export const dynamic = "force-dynamic";
2
2
 
3
- import { SignIn } from "@clerk/nextjs";
4
- import { sanitizeAuthRedirect } from "@/lib/sanitize-auth-redirect";
5
-
6
- interface Props {
7
- searchParams: Promise<{ redirect_url?: string }>;
8
- }
3
+ import { Suspense } from "react";
4
+ import { headers } from "next/headers";
5
+ import { requestOrigin } from "@/lib/request-origin";
6
+ import { StorefrontSignInClient } from "./StorefrontSignInClient";
9
7
 
10
8
  export const metadata = { title: "Sign in" };
11
9
 
12
- export default async function SignInPage({ searchParams }: Props) {
13
- const { redirect_url } = await searchParams;
14
- const redirectUrl = sanitizeAuthRedirect(redirect_url, "/dashboard");
15
-
10
+ export default async function SignInPage() {
11
+ const origin = requestOrigin(await headers());
16
12
  return (
17
13
  <div className="flex min-h-[calc(100vh-4rem)] items-center justify-center px-4">
18
- <SignIn
19
- fallbackRedirectUrl={redirectUrl}
20
- signUpUrl="/sign-up"
21
- />
14
+ <Suspense
15
+ fallback={
16
+ <p className="text-base text-sand-600 dark:text-sand-400">Loading sign in…</p>
17
+ }
18
+ >
19
+ <StorefrontSignInClient origin={origin} />
20
+ </Suspense>
22
21
  </div>
23
22
  );
24
23
  }