create-brainerce-store 1.88.1 → 1.89.0

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 (44) hide show
  1. package/dist/index.js +3 -2
  2. package/messages/en.json +21 -0
  3. package/messages/he.json +21 -0
  4. package/package.json +3 -2
  5. package/templates/nextjs/base/.eslintrc.json +1 -0
  6. package/templates/nextjs/base/AI-GUIDE.md +35 -0
  7. package/templates/nextjs/base/TRANSLATIONS.md +6 -0
  8. package/templates/nextjs/base/scripts/agent-docs.mjs +76 -0
  9. package/templates/nextjs/base/scripts/connect.mjs +6 -0
  10. package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +102 -96
  11. package/templates/nextjs/base/src/app/blog/[slug]/{page.tsx.ejs → page.tsx} +41 -144
  12. package/templates/nextjs/base/src/app/blog/page.tsx +187 -0
  13. package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +7 -3
  14. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +26 -11
  15. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +20 -4
  16. package/templates/nextjs/base/src/app/layout.tsx.ejs +18 -50
  17. package/templates/nextjs/base/src/app/page.tsx +109 -75
  18. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +7 -0
  19. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +267 -220
  20. package/templates/nextjs/base/src/app/products/page.tsx +184 -19
  21. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +9 -1
  22. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +30 -1
  23. package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +12 -6
  24. package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +9 -10
  25. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +15 -6
  26. package/templates/nextjs/base/src/components/seo/product-og-meta.tsx +46 -0
  27. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +24 -4
  28. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +99 -116
  29. package/templates/nextjs/base/src/core/hooks/use-reviews-enabled.ts +17 -0
  30. package/templates/nextjs/base/src/core/lib/listing-query.ts +199 -0
  31. package/templates/nextjs/base/src/core/lib/locale-path.ts +29 -0
  32. package/templates/nextjs/base/src/core/lib/reviews-disabled.ts +19 -0
  33. package/templates/nextjs/base/src/core/lib/server-messages.ts.ejs +34 -0
  34. package/templates/nextjs/base/src/core/lib/store-info.ts +20 -0
  35. package/templates/nextjs/base/src/core/providers/listing-seed.tsx +66 -0
  36. package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
  37. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +6 -2
  38. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +18 -50
  39. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +8 -2
  40. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +7 -2
  41. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +6 -2
  42. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +6 -2
  43. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +0 -277
  44. package/templates/nextjs/base/src/app/products/layout.tsx.ejs +0 -18
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "create-brainerce-store",
34
- version: "1.88.1",
34
+ version: "1.89.0",
35
35
  description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
36
36
  homepage: "https://brainerce.com",
37
37
  repository: {
@@ -57,8 +57,9 @@ var require_package = __commonJS({
57
57
  clean: "rimraf dist",
58
58
  release: "pnpm build && node ../../scripts/publish-workspace-package.js .",
59
59
  "check:template-parity": "node scripts/check-template-parity.js",
60
+ "check:agent-docs": "node scripts/check-agent-docs.js",
60
61
  "check:sdk-published": "node scripts/check-sdk-published.js",
61
- prepublishOnly: "node scripts/check-template-parity.js && node scripts/check-sdk-published.js && node ../../scripts/check-publishable.js ./package.json"
62
+ prepublishOnly: "node scripts/check-template-parity.js && node scripts/check-agent-docs.js && node scripts/check-sdk-published.js && node ../../scripts/check-publishable.js ./package.json"
62
63
  },
63
64
  dependencies: {
64
65
  chalk: "^4.1.2",
package/messages/en.json CHANGED
@@ -182,6 +182,25 @@
182
182
  "faq": {
183
183
  "title": "Frequently asked questions"
184
184
  },
185
+ "seo": {
186
+ "home": "Home",
187
+ "products": "Products",
188
+ "blog": "Blog",
189
+ "categoryNotFound": "Category not found",
190
+ "productNotFound": "Product not found",
191
+ "productsDescription": "Browse our full collection of products.",
192
+ "blogDescription": "Articles and stories from our store",
193
+ "pagination": "Catalog pages",
194
+ "previousPage": "Previous page",
195
+ "nextPage": "Next page"
196
+ },
197
+ "blog": {
198
+ "empty": "No posts yet.",
199
+ "readMore": "Read more",
200
+ "previous": "Previous",
201
+ "next": "Next",
202
+ "backToBlog": "Back to blog"
203
+ },
185
204
  "cart": {
186
205
  "pageTitle": "Shopping Cart",
187
206
  "title": "Shopping Cart",
@@ -258,6 +277,8 @@
258
277
  "paymentNotConfiguredDesc": "Payment has not been set up for this store yet. Please contact the store owner.",
259
278
  "paymentError": "Payment Error",
260
279
  "paymentUnavailable": "Payment is unavailable for this store right now. Please contact the store.",
280
+ "paymentSessionStuck": "This payment could not be continued. Start a new checkout to try again.",
281
+ "startNewCheckout": "Start a new checkout",
261
282
  "paymentRedirectBlocked": "Payment redirect blocked for security reasons. Please contact support.",
262
283
  "paymentLoadFailed": "The payment form didn't load. You can continue in a new tab instead.",
263
284
  "sandboxTitle": "Sandbox Mode",
package/messages/he.json CHANGED
@@ -182,6 +182,25 @@
182
182
  "faq": {
183
183
  "title": "שאלות נפוצות"
184
184
  },
185
+ "seo": {
186
+ "home": "דף הבית",
187
+ "products": "מוצרים",
188
+ "blog": "בלוג",
189
+ "categoryNotFound": "הקטגוריה לא נמצאה",
190
+ "productNotFound": "המוצר לא נמצא",
191
+ "productsDescription": "כל קטלוג המוצרים שלנו.",
192
+ "blogDescription": "מאמרים וסיפורים מהחנות",
193
+ "pagination": "עמודי הקטלוג",
194
+ "previousPage": "העמוד הקודם",
195
+ "nextPage": "העמוד הבא"
196
+ },
197
+ "blog": {
198
+ "empty": "עדיין אין פוסטים.",
199
+ "readMore": "קרא עוד",
200
+ "previous": "הקודם",
201
+ "next": "הבא",
202
+ "backToBlog": "חזרה לבלוג"
203
+ },
185
204
  "cart": {
186
205
  "pageTitle": "עגלת קניות",
187
206
  "title": "עגלת קניות",
@@ -258,6 +277,8 @@
258
277
  "paymentNotConfiguredDesc": "התשלום עדיין לא הוגדר לחנות זו. אנא פנו לבעל החנות.",
259
278
  "paymentError": "שגיאת תשלום",
260
279
  "paymentUnavailable": "לא ניתן לשלם בחנות זו כרגע. ניתן לפנות לחנות.",
280
+ "paymentSessionStuck": "לא ניתן להמשיך את התשלום הזה. אפשר להתחיל תשלום חדש ולנסות שוב.",
281
+ "startNewCheckout": "להתחיל תשלום חדש",
261
282
  "paymentRedirectBlocked": "הפניית התשלום נחסמה מטעמי אבטחה. אנא פנו לתמיכה.",
262
283
  "paymentLoadFailed": "טופס התשלום לא נטען. אפשר להמשיך בכרטיסייה חדשה במקום.",
263
284
  "sandboxTitle": "מצב בדיקה",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.88.1",
3
+ "version": "1.89.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "homepage": "https://brainerce.com",
6
6
  "repository": {
@@ -26,8 +26,9 @@
26
26
  "clean": "rimraf dist",
27
27
  "release": "pnpm build && node ../../scripts/publish-workspace-package.js .",
28
28
  "check:template-parity": "node scripts/check-template-parity.js",
29
+ "check:agent-docs": "node scripts/check-agent-docs.js",
29
30
  "check:sdk-published": "node scripts/check-sdk-published.js",
30
- "prepublishOnly": "node scripts/check-template-parity.js && node scripts/check-sdk-published.js && node ../../scripts/check-publishable.js ./package.json"
31
+ "prepublishOnly": "node scripts/check-template-parity.js && node scripts/check-agent-docs.js && node scripts/check-sdk-published.js && node ../../scripts/check-publishable.js ./package.json"
31
32
  },
32
33
  "dependencies": {
33
34
  "chalk": "^4.1.2",
@@ -22,6 +22,7 @@
22
22
  "!@/core/lib/coupon-error",
23
23
  "!@/core/lib/navigation",
24
24
  "!@/core/lib/product-options",
25
+ "!@/core/lib/reviews-disabled",
25
26
  "!@/core/lib/sanitize",
26
27
  "!@/core/lib/tracking",
27
28
  "!@/core/lib/sanitize-html",
@@ -198,6 +198,35 @@ attention, confirms an action, or rewards exploration. Rules:
198
198
  - **Fonts**: any face used for body or headlines must include the store's
199
199
  script (e.g. Google Fonts `hebrew` subset). Latin-only display faces are
200
200
  accent-only.
201
+ - **Public paths go through `localePath(locale, '/x')`** (`@/core/lib/locale-path`),
202
+ never `` `/${locale}/x` ``. The default locale arrives as a real `params.locale`
203
+ too, and the middleware 308-redirects `/{default}/x` to `/x`, so a canonical
204
+ tag, breadcrumb link or JSON-LD url built the second way points at a redirect.
205
+ - **Every indexable page sets its own `alternates.canonical`; the root layout sets
206
+ none.** A layout-level canonical is inherited by every page without its own and
207
+ tells Google they are duplicates of it. `openGraph` on a page REPLACES the
208
+ layout's object (it does not merge), so repeat `siteName` there.
209
+ - **Product meta description: `deriveSeoDescription(product)`**, never
210
+ `product.seoDescription` alone. The merchant's text can arrive under either
211
+ `seoDescription` or `metaDescription`; reading one silently falls back to the
212
+ start of the description.
213
+ - **`og:type: product` and the `product:*` price/availability tags are real
214
+ `<meta property>` elements** (`components/seo/product-og-meta.tsx`). Next's
215
+ `openGraph.type` has no `product`, and `metadata.other` renders `name=`, which
216
+ the crawlers that read these tags ignore. Do not also set `openGraph.type` on
217
+ the product page, or it carries two `og:type` tags.
218
+ - **`/products` and the home grid are server-seeded.** `app/products/page.tsx`
219
+ reads the URL (search, filters, sort, `?page=`) and passes the first render's
220
+ products through `ListingSeedProvider`; `core/lib/listing-query.ts` is the ONE
221
+ function turning a URL into a query, shared by that page and
222
+ `useProductListing`. Do not fork it, and do not put the listing back behind
223
+ `'use client'` or a `<Suspense>` fallback: the server HTML must contain the
224
+ products, not a spinner. A seeded hook starts with `loading` false.
225
+ - **Metadata, JSON-LD and breadcrumb strings come from the message files**
226
+ (`getServerNamespace('seo', locale)`), not from literals or a
227
+ `locale === 'he'` ternary (English for every third language). The visible
228
+ breadcrumb and the BreadcrumbList JSON-LD are built from the SAME items; the
229
+ JSON-LD components take a required `breadcrumbs` prop.
201
230
 
202
231
  ## Never do
203
232
 
@@ -219,6 +248,12 @@ attention, confirms an action, or rewards exploration. Rules:
219
248
 
220
249
  - New homepage section → new component in `src/ui/home/`, composed in
221
250
  `home-client.tsx`, data via existing hooks only.
251
+ - New place that shows a star rating or a review count (a card, a banner,
252
+ structured data) → gate it with `useReviewsEnabled()` (client) or
253
+ `reviewsAreOn(storeInfo)` from `src/core/lib/store-info` (server). Products
254
+ keep their stored `avgRating` and `reviewCount` while the merchant has reviews
255
+ switched off, so `reviewCount > 0` alone would show a rating for reviews nobody
256
+ can open.
222
257
  - New UI copy → add keys to **both** `messages/he.json` and
223
258
  `messages/en.json` (or all supported locales), use `useTranslations()`.
224
259
  - New microinteraction → CSS-first; JS only for things CSS cannot do.
@@ -79,6 +79,12 @@ The `StoreProvider` (`src/providers/store-provider.tsx`) calls `client.setLocale
79
79
 
80
80
  You never overlay translations yourself — the SDK does it on every request.
81
81
 
82
+ ### Meta descriptions
83
+
84
+ On a product read, `seoDescription` is the **effective** meta description: the active locale's translation when there is one, otherwise the merchant's stored text (which is also served as `metaDescription`). It is `null` only when the merchant wrote none. Never read one of the two names alone: call `deriveSeoDescription(product)`, which takes whichever is present and falls back to a stripped `description`, then the name. The product page (`src/app/products/[slug]/page.tsx`) does exactly that.
85
+
86
+ When you WRITE a product translation, send either name: `metaDescription` is accepted as `seoDescription`.
87
+
82
88
  ## RTL (Hebrew, Arabic, Persian, Urdu, Yiddish)
83
89
 
84
90
  `src/i18n.ts` exports `getDirection(locale)` that delegates to the SDK's `getDirectionForLocale()`. The layout uses it on `<html dir={…}>`:
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Keep CLAUDE.md and AGENTS.md truthful once `npm run connect` has connected
3
+ * the store.
4
+ *
5
+ * WHY THIS EXISTS. A project scaffolded with `--defer-connection` starts both
6
+ * files with a "Not connected yet" paragraph, rendered ONCE at scaffold time.
7
+ * `connect` then wrote the id into `.env.local` and never touched them, so a
8
+ * connected store kept telling every coding agent that read it that it was not
9
+ * connected. The agent believed the file, concluded the store had no channel and
10
+ * asked the user to connect it again.
11
+ *
12
+ * ⛔ THE PATTERN BELOW IS COUPLED TO TWO TEMPLATES. It must keep matching the
13
+ * deferred paragraph in `CLAUDE.md.ejs` and `AGENTS.md.ejs` (first words
14
+ * "**Not connected yet.**", last sentence "Once connected, everything below
15
+ * applies."). `scripts/check-template-parity.js` renders both files deferred and
16
+ * fails `prepublishOnly` if this stops matching, because a pattern that silently
17
+ * matches nothing is exactly the bug it fixes.
18
+ *
19
+ * Only the channel id goes into the prose, and only when it has the shape of one:
20
+ * the store NAME is owner-controlled text, and these files are read by agents as
21
+ * instructions. The name is not needed: the doc already says to read it from
22
+ * `resolveStoreName()`.
23
+ */
24
+
25
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { resolve } from 'node:path';
27
+
28
+ export const AGENT_DOC_FILES = ['CLAUDE.md', 'AGENTS.md'];
29
+
30
+ export const NOT_CONNECTED_PARAGRAPH =
31
+ /\*\*Not connected yet\.\*\*[\s\S]*?Once connected, everything below applies\./;
32
+
33
+ const CHANNEL_ID = /^vc_[A-Za-z0-9_-]+$/;
34
+
35
+ /** The paragraph a connected storefront's docs open with. Mirrors the
36
+ * non-deferred branch of the two templates, minus the store name. */
37
+ export function connectedParagraph(connectionId) {
38
+ return (
39
+ `**Sales channel \`${connectionId}\` — already connected.** Products, cart,\n` +
40
+ 'checkout, coupons, discounts, orders and content flow in real time from the\n' +
41
+ 'Brainerce dashboard. There is nothing to hook up.'
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Rewrite the "Not connected yet" paragraph in each agent doc under `dir`.
47
+ * Returns the names of the files it changed. Never throws: a doc that cannot be
48
+ * read or written must not undo a connection that already succeeded.
49
+ *
50
+ * A doc whose paragraph was edited by hand no longer matches and is left alone.
51
+ * A project that already connected before this script existed is not helped
52
+ * either: it carries the old `connect.mjs`, which never calls this. Its owner
53
+ * deletes the paragraph by hand (see the create-brainerce-store CHANGELOG).
54
+ */
55
+ export function markAgentDocsConnected(dir, connectionId) {
56
+ if (typeof connectionId !== 'string' || !CHANNEL_ID.test(connectionId)) return [];
57
+ const changed = [];
58
+ for (const name of AGENT_DOC_FILES) {
59
+ const path = resolve(dir, name);
60
+ try {
61
+ if (!existsSync(path)) continue;
62
+ const body = readFileSync(path, 'utf8');
63
+ if (!NOT_CONNECTED_PARAGRAPH.test(body)) continue;
64
+ // A function replacement, so `$` sequences in the id can never be read as
65
+ // replacement patterns.
66
+ const next = body.replace(NOT_CONNECTED_PARAGRAPH, () => connectedParagraph(connectionId));
67
+ // Keep a CRLF file CRLF (the paragraph is matched across either ending).
68
+ const out = body.includes('\r\n') ? next.replace(/\r?\n/g, '\r\n') : next;
69
+ writeFileSync(path, out);
70
+ changed.push(name);
71
+ } catch {
72
+ // Best effort by design.
73
+ }
74
+ }
75
+ return changed;
76
+ }
@@ -35,6 +35,7 @@ import { spawn } from 'node:child_process';
35
35
  import { dirname, resolve } from 'node:path';
36
36
  import { fileURLToPath } from 'node:url';
37
37
  import { setVar } from './env-file.mjs';
38
+ import { markAgentDocsConnected } from './agent-docs.mjs';
38
39
 
39
40
  const API_BASE = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(/\/$/, '');
40
41
  const ENV_PATH = resolve(process.cwd(), '.env.local');
@@ -248,6 +249,11 @@ function writeEnv(connectionId) {
248
249
  let body = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, 'utf8') : '';
249
250
  for (const key of KEYS) body = setVar(body, key, connectionId);
250
251
  writeFileSync(ENV_PATH, body);
252
+ // CLAUDE.md and AGENTS.md open with "Not connected yet" on a deferred scaffold.
253
+ // Left alone they tell the next coding agent this store has no channel, and it
254
+ // asks the user to connect it again.
255
+ const docs = markAgentDocsConnected(dirname(ENV_PATH), connectionId);
256
+ if (docs.length > 0) console.log(`Updated ${docs.join(' and ')}: the store is no longer "not connected".`);
251
257
  }
252
258
 
253
259
  /**
@@ -1,96 +1,102 @@
1
- import { NextRequest, NextResponse } from 'next/server';
2
-
3
- const TOKEN_COOKIE = 'brainerce_customer_token';
4
- const LOGGED_IN_COOKIE = 'brainerce_logged_in';
5
- const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
6
-
7
- const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
8
- /\/$/,
9
- ''
10
- );
11
-
12
- function isSecure(): boolean {
13
- return process.env.NODE_ENV === 'production';
14
- }
15
-
16
- /**
17
- * Forward a failure to the client callback page as a stable snake_case code.
18
- *
19
- * The backend uses the RFC 6749 §4.1.2.1 shape (`oauth_error` = code,
20
- * `error_description` = English developer detail), so codes are what the page
21
- * switches on to pick localized copy. The description is deliberately NOT
22
- * forwarded — it is not shopper-facing copy.
23
- */
24
- function errorRedirect(base: URL, code: string): NextResponse {
25
- base.searchParams.set('oauth_error', code);
26
- const response = NextResponse.redirect(base);
27
- response.headers.set('Referrer-Policy', 'no-referrer');
28
- return response;
29
- }
30
-
31
- /**
32
- * OAuth callback handler.
33
- *
34
- * The backend redirects here with ?oauth_success=true&auth_code=<one-time-code>.
35
- * We exchange the auth_code for the real JWT via a server-to-server POST (so the
36
- * JWT never appears in any URL), then set an httpOnly cookie and redirect to the
37
- * client-side callback page.
38
- */
39
- export async function GET(request: NextRequest) {
40
- const { searchParams } = request.nextUrl;
41
- const authCode = searchParams.get('auth_code');
42
- const oauthSuccess = searchParams.get('oauth_success');
43
- const oauthError = searchParams.get('oauth_error');
44
-
45
- const redirectUrl = new URL('/auth/callback', request.url);
46
-
47
- if (oauthError) {
48
- return errorRedirect(redirectUrl, oauthError);
49
- }
50
-
51
- if (oauthSuccess !== 'true' || !authCode) {
52
- return errorRedirect(redirectUrl, 'invalid_request');
53
- }
54
-
55
- // Exchange the one-time auth_code for the real JWT.
56
- // This is a server-to-server call — the JWT never enters the browser URL.
57
- let token: string;
58
- try {
59
- const exchangeRes = await fetch(`${BACKEND_URL}/api/oauth/customer/exchange`, {
60
- method: 'POST',
61
- headers: { 'Content-Type': 'application/json' },
62
- body: JSON.stringify({ code: authCode }),
63
- });
64
- if (!exchangeRes.ok) {
65
- throw new Error(`exchange responded ${exchangeRes.status}`);
66
- }
67
- const data = await exchangeRes.json();
68
- if (!data.token) throw new Error('missing token in exchange response');
69
- token = data.token;
70
- } catch (err) {
71
- console.error('[oauth-callback] auth_code exchange failed:', err);
72
- return errorRedirect(redirectUrl, 'server_error');
73
- }
74
-
75
- redirectUrl.searchParams.set('oauth_success', 'true');
76
- const response = NextResponse.redirect(redirectUrl);
77
-
78
- response.cookies.set(TOKEN_COOKIE, token, {
79
- httpOnly: true,
80
- secure: isSecure(),
81
- sameSite: 'lax',
82
- path: '/',
83
- maxAge: COOKIE_MAX_AGE,
84
- });
85
-
86
- response.cookies.set(LOGGED_IN_COOKIE, '1', {
87
- httpOnly: false,
88
- secure: isSecure(),
89
- sameSite: 'lax',
90
- path: '/',
91
- maxAge: COOKIE_MAX_AGE,
92
- });
93
-
94
- response.headers.set('Referrer-Policy', 'no-referrer');
95
- return response;
96
- }
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { REFERRAL_COOKIE } from '@/core/lib/referral';
3
+
4
+ const TOKEN_COOKIE = 'brainerce_customer_token';
5
+ const LOGGED_IN_COOKIE = 'brainerce_logged_in';
6
+ const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
7
+
8
+ const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(
9
+ /\/$/,
10
+ ''
11
+ );
12
+
13
+ function isSecure(): boolean {
14
+ return process.env.NODE_ENV === 'production';
15
+ }
16
+
17
+ /**
18
+ * Forward a failure to the client callback page as a stable snake_case code.
19
+ *
20
+ * The backend uses the RFC 6749 §4.1.2.1 shape (`oauth_error` = code,
21
+ * `error_description` = English developer detail), so codes are what the page
22
+ * switches on to pick localized copy. The description is deliberately NOT
23
+ * forwarded — it is not shopper-facing copy.
24
+ */
25
+ function errorRedirect(base: URL, code: string): NextResponse {
26
+ base.searchParams.set('oauth_error', code);
27
+ const response = NextResponse.redirect(base);
28
+ response.headers.set('Referrer-Policy', 'no-referrer');
29
+ return response;
30
+ }
31
+
32
+ /**
33
+ * OAuth callback handler.
34
+ *
35
+ * The backend redirects here with ?oauth_success=true&auth_code=<one-time-code>.
36
+ * We exchange the auth_code for the real JWT via a server-to-server POST (so the
37
+ * JWT never appears in any URL), then set an httpOnly cookie and redirect to the
38
+ * client-side callback page.
39
+ */
40
+ export async function GET(request: NextRequest) {
41
+ const { searchParams } = request.nextUrl;
42
+ const authCode = searchParams.get('auth_code');
43
+ const oauthSuccess = searchParams.get('oauth_success');
44
+ const oauthError = searchParams.get('oauth_error');
45
+
46
+ const redirectUrl = new URL('/auth/callback', request.url);
47
+
48
+ if (oauthError) {
49
+ return errorRedirect(redirectUrl, oauthError);
50
+ }
51
+
52
+ if (oauthSuccess !== 'true' || !authCode) {
53
+ return errorRedirect(redirectUrl, 'invalid_request');
54
+ }
55
+
56
+ // Exchange the one-time auth_code for the real JWT.
57
+ // This is a server-to-server call — the JWT never enters the browser URL.
58
+ let token: string;
59
+ try {
60
+ const exchangeRes = await fetch(`${BACKEND_URL}/api/oauth/customer/exchange`, {
61
+ method: 'POST',
62
+ headers: { 'Content-Type': 'application/json' },
63
+ body: JSON.stringify({ code: authCode }),
64
+ });
65
+ if (!exchangeRes.ok) {
66
+ throw new Error(`exchange responded ${exchangeRes.status}`);
67
+ }
68
+ const data = await exchangeRes.json();
69
+ if (!data.token) throw new Error('missing token in exchange response');
70
+ token = data.token;
71
+ } catch (err) {
72
+ console.error('[oauth-callback] auth_code exchange failed:', err);
73
+ return errorRedirect(redirectUrl, 'server_error');
74
+ }
75
+
76
+ redirectUrl.searchParams.set('oauth_success', 'true');
77
+ const response = NextResponse.redirect(redirectUrl);
78
+
79
+ response.cookies.set(TOKEN_COOKIE, token, {
80
+ httpOnly: true,
81
+ secure: isSecure(),
82
+ sameSite: 'lax',
83
+ path: '/',
84
+ maxAge: COOKIE_MAX_AGE,
85
+ });
86
+
87
+ response.cookies.set(LOGGED_IN_COOKIE, '1', {
88
+ httpOnly: false,
89
+ secure: isSecure(),
90
+ sameSite: 'lax',
91
+ path: '/',
92
+ maxAge: COOKIE_MAX_AGE,
93
+ });
94
+
95
+ // The referral code rode the OAuth state and is now spent (or ignored, for
96
+ // an existing customer). Drop it so the next account created in this
97
+ // browser is not attributed to the same referrer — same as /register does.
98
+ response.cookies.set(REFERRAL_COOKIE, '', { path: '/', maxAge: 0, sameSite: 'lax' });
99
+
100
+ response.headers.set('Referrer-Policy', 'no-referrer');
101
+ return response;
102
+ }