@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
|
@@ -2,124 +2,141 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* ShopClient — pick a plan, see what it costs, pay.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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=
|
|
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
|
|
81
|
-
{
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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="
|
|
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=
|
|
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=
|
|
121
|
+
<p className={`mt-1 text-sm ${secondary}`}>
|
|
105
122
|
{selected
|
|
106
123
|
? `${formatDataAllowance(selected.dataGb, selected.unlimited)} · ${formatValidity(selected.validityDays)}`
|
|
107
|
-
: "Choose a
|
|
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-
|
|
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=
|
|
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=
|
|
119
|
-
<dd className="font-mono tabular-nums text-
|
|
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=
|
|
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=
|
|
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
|
|
159
|
+
{selected?.price_unavailable
|
|
160
|
+
? "Price unavailable"
|
|
161
|
+
: selected
|
|
162
|
+
? "Continue to payment"
|
|
163
|
+
: "Select a plan first"}
|
|
144
164
|
</button>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
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(
|
|
13
|
-
const
|
|
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
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
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
|
}
|