create-brainerce-store 1.80.0 → 1.81.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 (29) hide show
  1. package/dist/index.js +152 -9
  2. package/messages/en.json +8 -1
  3. package/messages/he.json +8 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/.eslintrc.json +2 -0
  6. package/templates/nextjs/base/AI-GUIDE.md +32 -2
  7. package/templates/nextjs/base/package.json.ejs +53 -52
  8. package/templates/nextjs/base/scripts/connect.mjs +149 -0
  9. package/templates/nextjs/base/src/app/checkout/page.tsx +18 -0
  10. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +22 -0
  11. package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +49 -0
  12. package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +18 -0
  13. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +49 -0
  14. package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +96 -2
  15. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +17 -0
  16. package/templates/nextjs/base/src/ui/product/product-card.tsx +17 -0
  17. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +13 -0
  18. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +18 -0
  19. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +49 -0
  20. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +101 -5
  21. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +17 -0
  22. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +17 -0
  23. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +13 -0
  24. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +16 -0
  25. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +45 -0
  26. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +95 -2
  27. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +15 -0
  28. package/templates/nextjs/ui-canvas/product/product-card.tsx +15 -0
  29. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +13 -0
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.80.0",
34
+ version: "1.81.0",
35
35
  description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
36
36
  homepage: "https://brainerce.com",
37
37
  repository: {
@@ -914,6 +914,105 @@ var logger = {
914
914
  }
915
915
  };
916
916
 
917
+ // src/device-flow.ts
918
+ var import_node_child_process = require("child_process");
919
+ function isOpenableUrl(value, apiBaseUrl) {
920
+ let url;
921
+ try {
922
+ url = new URL(value);
923
+ } catch {
924
+ return false;
925
+ }
926
+ if (url.protocol !== "https:" && url.protocol !== "http:") return false;
927
+ const apiHost = (() => {
928
+ try {
929
+ return new URL(apiBaseUrl).hostname;
930
+ } catch {
931
+ return "";
932
+ }
933
+ })();
934
+ const base = apiHost.replace(/^api(-staging)?\./, "");
935
+ return url.hostname === base || url.hostname.endsWith(`.${base}`);
936
+ }
937
+ function openBrowser(url) {
938
+ const platform = process.platform;
939
+ const [cmd, args] = platform === "win32" ? ["cmd", ["/c", "start", "", url]] : platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
940
+ try {
941
+ const child = (0, import_node_child_process.spawn)(cmd, args, { stdio: "ignore", detached: true });
942
+ child.on("error", () => {
943
+ });
944
+ child.unref();
945
+ return true;
946
+ } catch {
947
+ return false;
948
+ }
949
+ }
950
+ async function runDeviceFlow(apiBaseUrl, options = {}) {
951
+ let start;
952
+ try {
953
+ const res = await fetch(`${apiBaseUrl}/api/device-auth/start`, {
954
+ method: "POST",
955
+ headers: { "Content-Type": "application/json" },
956
+ body: JSON.stringify({
957
+ clientName: options.clientName || process.env.BRAINERCE_CLIENT_NAME || "create-brainerce-store"
958
+ })
959
+ });
960
+ if (!res.ok) {
961
+ logger.error(
962
+ `Could not start the browser approval (HTTP ${res.status}).
963
+ Create a sales channel in the dashboard instead, then re-run with --connection-id vc_xxx.`
964
+ );
965
+ return null;
966
+ }
967
+ start = await res.json();
968
+ } catch (error) {
969
+ logger.error(
970
+ `Could not reach Brainerce to start the approval: ${error instanceof Error ? error.message : String(error)}`
971
+ );
972
+ return null;
973
+ }
974
+ const opened = isOpenableUrl(start.verificationUriComplete, apiBaseUrl) && openBrowser(start.verificationUriComplete);
975
+ console.log();
976
+ logger.info(
977
+ opened ? " Opened your browser to approve this connection." : " Open this link to approve this connection:"
978
+ );
979
+ console.log(` ${start.verificationUriComplete}`);
980
+ console.log(` The page should show the code ${start.userCode} \u2014 check that it matches.`);
981
+ console.log();
982
+ logger.info(" Waiting for approval. A store and a sales channel are created if you have none.");
983
+ const deadline = Date.now() + start.expiresIn * 1e3;
984
+ let interval = (start.interval || 5) * 1e3;
985
+ while (Date.now() < deadline) {
986
+ await new Promise((resolve) => setTimeout(resolve, interval));
987
+ let result;
988
+ try {
989
+ const res = await fetch(`${apiBaseUrl}/api/device-auth/poll`, {
990
+ method: "POST",
991
+ headers: { "Content-Type": "application/json" },
992
+ body: JSON.stringify({ deviceCode: start.deviceCode })
993
+ });
994
+ result = await res.json();
995
+ } catch {
996
+ continue;
997
+ }
998
+ if (result.status === "approved" && result.connectionId) {
999
+ logger.success(" Approved.");
1000
+ return { connectionId: result.connectionId, storeId: result.storeId ?? null };
1001
+ }
1002
+ if (result.status === "denied") {
1003
+ logger.error(" The connection request was declined. Nothing was created.");
1004
+ return null;
1005
+ }
1006
+ if (result.status === "expired") {
1007
+ logger.error(" The code expired before it was approved. Run the command again.");
1008
+ return null;
1009
+ }
1010
+ if (result.status === "slow_down") interval += 1e3;
1011
+ }
1012
+ logger.error(" Timed out waiting for approval. Run the command again for a fresh code.");
1013
+ return null;
1014
+ }
1015
+
917
1016
  // src/utils/spinner.ts
918
1017
  var import_ora = __toESM(require("ora"));
919
1018
  function createSpinner(text) {
@@ -959,6 +1058,7 @@ async function checkForUpdate(name, current) {
959
1058
  return null;
960
1059
  }
961
1060
  }
1061
+ var DEFERRED_CONNECTION_ID = "vc_REPLACE_ME_RUN_npm_run_connect";
962
1062
  async function resolveStoreInfoOrExit(connectionId, candidateApiUrls) {
963
1063
  const spinner = createSpinner("Fetching store info...");
964
1064
  spinner.start();
@@ -989,6 +1089,9 @@ async function resolveStoreInfoOrExit(connectionId, candidateApiUrls) {
989
1089
  }
990
1090
  var program = new import_commander.Command();
991
1091
  program.name("create-brainerce-store").description("Scaffold a production-ready e-commerce storefront connected to Brainerce").version(pkg.version).argument("[project-name]", "Name for the project directory").option("--connection-id <id>", "Brainerce vibe-coded connection ID (vc_*)").option(
1092
+ "--defer-connection",
1093
+ "Scaffold now and connect later. Skips the browser approval and writes a placeholder id; finish with `npm run connect` inside the project."
1094
+ ).option(
992
1095
  "--api-url <url>",
993
1096
  "Brainerce API base URL (overrides BRAINERCE_API_URL env, defaults to https://api.brainerce.com)"
994
1097
  ).option("--language <lang>", "Store language (en, he)").addOption(new import_commander.Option("--framework <framework>", "Framework to use").default("nextjs").hideHelp()).addOption(new import_commander.Option("--design <design>", "Storefront design pack").hideHelp()).option("--pkg-manager <manager>", "Package manager (npm, pnpm, yarn, bun)").option(
@@ -1058,11 +1161,28 @@ program.name("create-brainerce-store").description("Scaffold a production-ready
1058
1161
  let storeInfo = null;
1059
1162
  let resolvedApiUrl = candidateApiUrls[0];
1060
1163
  if (connectionId) {
1061
- const connError2 = validateConnectionId(connectionId);
1062
- if (connError2) {
1063
- logger.error(connError2);
1164
+ const connError = validateConnectionId(connectionId);
1165
+ if (connError) {
1166
+ logger.error(connError);
1167
+ process.exit(1);
1168
+ }
1169
+ const resolved = await resolveStoreInfoOrExit(connectionId, candidateApiUrls);
1170
+ storeInfo = resolved.info;
1171
+ resolvedApiUrl = resolved.apiBaseUrl;
1172
+ } else if (options.deferConnection === true) {
1173
+ connectionId = DEFERRED_CONNECTION_ID;
1174
+ storeInfo = {
1175
+ name: projectName || "My Store",
1176
+ storeName: projectName || "My Store",
1177
+ currency: "USD",
1178
+ language: language || "en"
1179
+ };
1180
+ } else {
1181
+ const approved = await runDeviceFlow(candidateApiUrls[0]);
1182
+ if (!approved) {
1064
1183
  process.exit(1);
1065
1184
  }
1185
+ connectionId = approved.connectionId;
1066
1186
  const resolved = await resolveStoreInfoOrExit(connectionId, candidateApiUrls);
1067
1187
  storeInfo = resolved.info;
1068
1188
  resolvedApiUrl = resolved.apiBaseUrl;
@@ -1072,6 +1192,16 @@ program.name("create-brainerce-store").description("Scaffold a production-ready
1072
1192
  const storeSlug = storeInfo ? slugify(storeInfo.storeName) : "";
1073
1193
  const projectNameSuggestion = channelSlug || storeSlug || void 0;
1074
1194
  language = language || storeInfo?.language;
1195
+ if (!projectName && !process.stdin.isTTY) {
1196
+ if (!projectNameSuggestion) {
1197
+ logger.error(
1198
+ "No project name given, and no terminal to ask on. Pass one: npm create brainerce-store@latest my-store"
1199
+ );
1200
+ process.exit(1);
1201
+ }
1202
+ projectName = projectNameSuggestion;
1203
+ logger.info(` No project name given. Using "${projectName}", from your store.`);
1204
+ }
1075
1205
  if (!projectName || !connectionId || !language) {
1076
1206
  const answers = await runInteractive({
1077
1207
  projectName,
@@ -1095,12 +1225,14 @@ program.name("create-brainerce-store").description("Scaffold a production-ready
1095
1225
  logger.error(nameError);
1096
1226
  process.exit(1);
1097
1227
  }
1098
- const connError = validateConnectionId(connectionId);
1099
- if (connError) {
1100
- logger.error(connError);
1101
- process.exit(1);
1228
+ if (connectionId !== DEFERRED_CONNECTION_ID) {
1229
+ const connError = validateConnectionId(connectionId);
1230
+ if (connError) {
1231
+ logger.error(connError);
1232
+ process.exit(1);
1233
+ }
1102
1234
  }
1103
- if (!storeInfo) {
1235
+ if (!storeInfo && connectionId !== DEFERRED_CONNECTION_ID) {
1104
1236
  const resolved = await resolveStoreInfoOrExit(connectionId, candidateApiUrls);
1105
1237
  storeInfo = resolved.info;
1106
1238
  resolvedApiUrl = resolved.apiBaseUrl;
@@ -1196,7 +1328,18 @@ Your store will be running at http://localhost:3000
1196
1328
  if (!scaffoldInPlace) {
1197
1329
  logger.step(`cd ${projectName}`);
1198
1330
  }
1331
+ if (connectionId === DEFERRED_CONNECTION_ID) {
1332
+ logger.step(`${pkgManager}${pkgManager === "npm" ? " run" : ""} connect`);
1333
+ }
1199
1334
  logger.step(`${pkgManager}${pkgManager === "npm" ? " run" : ""} dev`);
1335
+ if (connectionId === DEFERRED_CONNECTION_ID) {
1336
+ logger.info(
1337
+ `
1338
+ This store is not connected yet. \`connect\` opens one browser approval,
1339
+ writes the sales channel id into .env.local, and creates a store and a
1340
+ channel for you if you have neither. Until then the catalog is empty.`
1341
+ );
1342
+ }
1200
1343
  logger.info(`
1201
1344
  Your store will be running at http://localhost:3000`);
1202
1345
  logger.info(
package/messages/en.json CHANGED
@@ -104,6 +104,8 @@
104
104
  "addToCart": "Add to Cart",
105
105
  "addingToCart": "Adding...",
106
106
  "addedToCart": "Added to Cart!",
107
+ "cartFull": "Your cart is full. Remove an item before adding this one.",
108
+ "addToCartFailed": "We could not add this to your cart. Please try again.",
107
109
  "outOfStock": "Out of Stock",
108
110
  "inStock": "In Stock",
109
111
  "unavailable": "Unavailable",
@@ -191,12 +193,17 @@
191
193
  "upgrade": "Upgrade",
192
194
  "upgrading": "Upgrading...",
193
195
  "dismissUpgrade": "Dismiss",
196
+ "upgradeOriginalNotRemoved": "We added the upgrade to your cart, but we could not remove the original item. Press Upgrade again to remove it.",
194
197
  "bundleOffers": "Bundle & Save",
195
198
  "addBundleItem": "Add & Save",
196
199
  "addingBundle": "Adding...",
197
200
  "selectOptions": "Select options",
198
201
  "outOfStock": "Out of stock",
199
- "unavailableItemsHint": "Remove the items marked out of stock or unavailable to continue to checkout."
202
+ "unavailableItemsHint": "Remove the items marked out of stock or unavailable to continue to checkout.",
203
+ "notEnoughStock": "There is not enough stock left for that quantity.",
204
+ "itemUnavailable": "This item can no longer be bought. Remove it to continue.",
205
+ "quantityUpdateFailed": "We could not update the quantity. Please try again.",
206
+ "removeFailed": "We could not remove this item. Please try again."
200
207
  },
201
208
  "checkout": {
202
209
  "pageTitle": "Checkout",
package/messages/he.json CHANGED
@@ -104,6 +104,8 @@
104
104
  "addToCart": "הוסף לעגלה",
105
105
  "addingToCart": "מוסיף...",
106
106
  "addedToCart": "נוסף לעגלה!",
107
+ "cartFull": "העגלה מלאה. הסירו מוצר אחד לפני הוספת המוצר הזה.",
108
+ "addToCartFailed": "לא הצלחנו להוסיף את המוצר לעגלה. נסו שוב.",
107
109
  "outOfStock": "אזל מהמלאי",
108
110
  "inStock": "במלאי",
109
111
  "unavailable": "לא זמין",
@@ -191,12 +193,17 @@
191
193
  "upgrade": "שדרג",
192
194
  "upgrading": "משדרג...",
193
195
  "dismissUpgrade": "סגור",
196
+ "upgradeOriginalNotRemoved": "הוספנו את השדרוג לעגלה, אבל לא הצלחנו להסיר את המוצר המקורי. לחצו שוב על שדרוג כדי להסיר אותו.",
194
197
  "bundleOffers": "חבילה וחיסכון",
195
198
  "addBundleItem": "הוסף וחסוך",
196
199
  "addingBundle": "מוסיף...",
197
200
  "selectOptions": "בחר אפשרויות",
198
201
  "outOfStock": "אזל מהמלאי",
199
- "unavailableItemsHint": "הסירו את המוצרים שמסומנים כאזלו מהמלאי או כלא זמינים כדי להמשיך לתשלום."
202
+ "unavailableItemsHint": "הסירו את המוצרים שמסומנים כאזלו מהמלאי או כלא זמינים כדי להמשיך לתשלום.",
203
+ "notEnoughStock": "אין מספיק מלאי לכמות הזו.",
204
+ "itemUnavailable": "לא ניתן לרכוש את המוצר הזה יותר. הסירו אותו כדי להמשיך.",
205
+ "quantityUpdateFailed": "לא הצלחנו לעדכן את הכמות. נסו שוב.",
206
+ "removeFailed": "לא הצלחנו להסיר את המוצר. נסו שוב."
200
207
  },
201
208
  "checkout": {
202
209
  "pageTitle": "תשלום",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.80.0",
3
+ "version": "1.81.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "homepage": "https://brainerce.com",
6
6
  "repository": {
@@ -17,6 +17,8 @@
17
17
  "!@/core/lib/capabilities",
18
18
  "!@/core/lib/display-price",
19
19
  "!@/core/lib/image-hosts",
20
+ "!@/core/lib/kit",
21
+ "!@/core/lib/add-to-cart-error",
20
22
  "!@/core/lib/navigation",
21
23
  "!@/core/lib/product-options",
22
24
  "!@/core/lib/sanitize",
@@ -59,8 +59,38 @@ UI components get *everything* from `@/core/hooks/*` and
59
59
  - `useHomeData()` → `{ products, banners, loading }`
60
60
  - `useProductListing()` → products + filters/sort/facets/pagination + handlers
61
61
  - `useProductPage(product)` → variant/image selection, `priceInfo`, `inventory`,
62
- `quantity`, `handleAddToCart`, `addingToCart`, `addedMessage`, customization
63
- + modifier state and errors
62
+ `quantity`, `handleAddToCart`, `addingToCart`, `addedMessage`, `addToCartError`,
63
+ customization + modifier state and errors
64
+ - `addToCartError` is `'CART_FULL' | 'FAILED' | null`. **Render it, and render it
65
+ outside any `modifierGroups.length > 0` block** — a cart holds at most 50
66
+ distinct products and the 51st add is refused, so a design that drops this
67
+ leaves the shopper tapping a button that resets and adds nothing. Copy lives
68
+ in `productDetail.cartFull` / `productDetail.addToCartFailed`.
69
+ - Five more components hold the same message in their OWN local state, because
70
+ they add to the cart without this hook: `ui/product/product-card.tsx`,
71
+ `ui/product/frequently-bought-together.tsx`, `ui/cart/cart-bundle-offer.tsx`
72
+ (all three: `addError`), `app/checkout/page.tsx` (`bumpError`) and
73
+ `ui/cart/cart-upgrade-banner.tsx` (`upgradeError`). All five use
74
+ `toAddToCartError()` from `@/core/lib/add-to-cart-error`. **Nothing checks that
75
+ you kept them** — `check-template-parity.js` compares `@/ui/...` imports and SDK
76
+ calls only, so dropping one of these leaves a green build and a silent button.
77
+ - `ui/cart/cart-item.tsx` carries the same idea for the two writes it owns,
78
+ in its own `lineError` state: a failed quantity change or a failed Remove
79
+ used to log to the console and leave the row looking untouched. It uses
80
+ `getErrorCode()` from the same file (not `toAddToCartError()` — the 50-line
81
+ cap cannot refuse a quantity change, and "we could not add this" would be a
82
+ lie about a removal) and four `cart.*` strings, mapped in `LINE_ERROR_KEYS`:
83
+ `notEnoughStock`, `itemUnavailable`, `quantityUpdateFailed`, `removeFailed`.
84
+ The first two are for refusals a retry can never clear, so do not collapse
85
+ them into the generic one. Nothing checks that you kept any of this either.
86
+ - `ui/cart/cart-upgrade-banner.tsx` additionally swaps one cart line for
87
+ another, and the ORDER of its two SDK calls is a correctness property, not a
88
+ style choice: it **adds the upgrade first and removes the original second**,
89
+ so a failed add leaves the cart untouched instead of losing the shopper's
90
+ line. It also carries `upgradeAdded`, which stops a retry adding the upgrade
91
+ twice, and a third message, `cart.upgradeOriginalNotRemoved`, for the case
92
+ where the add landed and the removal did not. If you rebuild that component,
93
+ keep all three; the reasoning is in the JSDoc on `handleUpgrade`.
64
94
  - `useCartPage()` / `useCart()` → `{ cart, itemCount, totals, refreshCart }`
65
95
 
66
96
  Hooks return data and handlers, never JSX. All catalog content (names, prices,
@@ -1,52 +1,53 @@
1
- {
2
- "name": "<%= projectName %>",
3
- "version": "0.1.0",
4
- "private": true,
5
- "scripts": {
6
- "dev": "next dev",
7
- "build": "next build",
8
- "start": "next start",
9
- "lint": "next lint",
10
- "setup": "node scripts/fetch-store-info.mjs"
11
- },
12
- "dependencies": {
13
- "brainerce": "<%= brainerceVersion %>",
14
- "next": "^15.3.4",
15
- "react": "^19.0.0",
16
- "react-dom": "^19.0.0",
17
- "@radix-ui/react-accordion": "^1.2.0",
18
- "@radix-ui/react-checkbox": "^1.1.1",
19
- "@radix-ui/react-dialog": "^1.1.2",
20
- "@radix-ui/react-label": "^2.1.0",
21
- "@radix-ui/react-radio-group": "^1.2.0",
22
- "@radix-ui/react-select": "^2.1.1",
23
- "@radix-ui/react-separator": "^1.1.0",
24
- "@radix-ui/react-slot": "^1.1.0",
25
- "@radix-ui/react-tabs": "^1.1.0",
26
- "@radix-ui/react-tooltip": "^1.1.4",
27
- "class-variance-authority": "^0.7.1",
28
- "clsx": "^2.1.1",
29
- "lucide-react": "^0.462.0",
30
- "tailwind-merge": "^2.5.2",
31
- "tailwindcss-animate": "^1.0.7",
32
- "isomorphic-dompurify": "<%= isomorphicDompurifyVersion %>",
33
- "sharp": "^0.35.3"
34
- },
35
- "devDependencies": {
36
- "@types/node": "^20.0.0",
37
- "@types/react": "^19.0.0",
38
- "@types/react-dom": "^19.0.0",
39
- "autoprefixer": "^10.4.0",
40
- "eslint": "^9.0.0",
41
- "eslint-config-next": "^15.3.4",
42
- "postcss": "^8.4.49",
43
- "tailwindcss": "^3.4.0",
44
- "typescript": "^5.4.0"
45
- },
46
- "engines": {
47
- "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
48
- },
49
- "pnpm": {
50
- "onlyBuiltDependencies": ["sharp"]
51
- }
52
- }
1
+ {
2
+ "name": "<%= projectName %>",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "lint": "next lint",
10
+ "setup": "node scripts/fetch-store-info.mjs",
11
+ "connect": "node scripts/connect.mjs"
12
+ },
13
+ "dependencies": {
14
+ "brainerce": "<%= brainerceVersion %>",
15
+ "next": "^15.3.4",
16
+ "react": "^19.0.0",
17
+ "react-dom": "^19.0.0",
18
+ "@radix-ui/react-accordion": "^1.2.0",
19
+ "@radix-ui/react-checkbox": "^1.1.1",
20
+ "@radix-ui/react-dialog": "^1.1.2",
21
+ "@radix-ui/react-label": "^2.1.0",
22
+ "@radix-ui/react-radio-group": "^1.2.0",
23
+ "@radix-ui/react-select": "^2.1.1",
24
+ "@radix-ui/react-separator": "^1.1.0",
25
+ "@radix-ui/react-slot": "^1.1.0",
26
+ "@radix-ui/react-tabs": "^1.1.0",
27
+ "@radix-ui/react-tooltip": "^1.1.4",
28
+ "class-variance-authority": "^0.7.1",
29
+ "clsx": "^2.1.1",
30
+ "lucide-react": "^0.462.0",
31
+ "tailwind-merge": "^2.5.2",
32
+ "tailwindcss-animate": "^1.0.7",
33
+ "isomorphic-dompurify": "<%= isomorphicDompurifyVersion %>",
34
+ "sharp": "^0.35.3"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^20.0.0",
38
+ "@types/react": "^19.0.0",
39
+ "@types/react-dom": "^19.0.0",
40
+ "autoprefixer": "^10.4.0",
41
+ "eslint": "^9.0.0",
42
+ "eslint-config-next": "^15.3.4",
43
+ "postcss": "^8.4.49",
44
+ "tailwindcss": "^3.4.0",
45
+ "typescript": "^5.4.0"
46
+ },
47
+ "engines": {
48
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
49
+ },
50
+ "pnpm": {
51
+ "onlyBuiltDependencies": ["sharp"]
52
+ }
53
+ }
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Connect this storefront to a Brainerce sales channel.
4
+ *
5
+ * Run it when the build is finished, not before:
6
+ *
7
+ * npm run connect
8
+ *
9
+ * WHY THIS IS A SEPARATE STEP. Nothing in a storefront's code depends on WHICH
10
+ * store it points at — the pages, the routing, the cart, the checkout and every
11
+ * SDK call are identical either way. Only one environment variable differs. So
12
+ * a run scaffolded with `--defer-connection` builds the whole storefront
13
+ * uninterrupted and comes here at the end, instead of stopping for an approval
14
+ * before a single file exists.
15
+ *
16
+ * One click in a browser, and this writes the id into `.env.local`. A store and
17
+ * a sales channel are created for you if you have neither.
18
+ *
19
+ * ⛔ NEVER PROMPTS. This is run by coding agents in shells with no TTY, where a
20
+ * prompt does not fail — it hangs. Everything is printed and polled.
21
+ */
22
+
23
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
24
+ import { spawn } from 'node:child_process';
25
+ import { resolve } from 'node:path';
26
+
27
+ const API_BASE = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').replace(/\/$/, '');
28
+ const ENV_PATH = resolve(process.cwd(), '.env.local');
29
+ const KEYS = ['NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID', 'NEXT_PUBLIC_BRAINERCE_CONNECTION_ID'];
30
+
31
+ /** Only ever hand the OS a URL on the host we asked. It arrives in a network
32
+ * response, so it is untrusted input on the way to a process launch. */
33
+ function openable(value) {
34
+ try {
35
+ const url = new URL(value);
36
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return false;
37
+ const base = new URL(API_BASE).hostname.replace(/^api(-staging)?\./, '');
38
+ return url.hostname === base || url.hostname.endsWith(`.${base}`);
39
+ } catch {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ function openBrowser(url) {
45
+ const [cmd, args] =
46
+ process.platform === 'win32'
47
+ ? ['cmd', ['/c', 'start', '', url]]
48
+ : process.platform === 'darwin'
49
+ ? ['open', [url]]
50
+ : ['xdg-open', [url]];
51
+ try {
52
+ // No shell, argument array: the URL cannot become a command.
53
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
54
+ child.on('error', () => {});
55
+ child.unref();
56
+ return true;
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /** Rewrite in place so hand-added variables survive. A blind overwrite here
63
+ * would silently drop a payment key someone pasted in an hour earlier. */
64
+ function writeEnv(connectionId) {
65
+ let body = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, 'utf8') : '';
66
+ for (const key of KEYS) {
67
+ const line = `${key}=${connectionId}`;
68
+ const pattern = new RegExp(`^${key}=.*$`, 'm');
69
+ body = pattern.test(body) ? body.replace(pattern, line) : `${body.trimEnd()}\n${line}\n`;
70
+ }
71
+ writeFileSync(ENV_PATH, body.startsWith('\n') ? body.slice(1) : body);
72
+ }
73
+
74
+ async function main() {
75
+ if (process.env.BRAINERCE_CONNECTION_ID) {
76
+ // Already known (CI, or a channel someone created by hand). Nothing to approve.
77
+ writeEnv(process.env.BRAINERCE_CONNECTION_ID.trim());
78
+ console.log('Wrote BRAINERCE_CONNECTION_ID from the environment into .env.local.');
79
+ return;
80
+ }
81
+
82
+ let start;
83
+ try {
84
+ const res = await fetch(`${API_BASE}/api/device-auth/start`, {
85
+ method: 'POST',
86
+ headers: { 'Content-Type': 'application/json' },
87
+ body: JSON.stringify({ clientName: process.env.BRAINERCE_CLIENT_NAME || 'this storefront' }),
88
+ });
89
+ if (!res.ok) {
90
+ console.error(`Could not start the approval (HTTP ${res.status}).`);
91
+ console.error('Create a sales channel in the dashboard, then set the id in .env.local.');
92
+ process.exit(1);
93
+ }
94
+ start = await res.json();
95
+ } catch (error) {
96
+ console.error(`Could not reach Brainerce: ${error?.message ?? error}`);
97
+ process.exit(1);
98
+ }
99
+
100
+ const opened = openable(start.verificationUriComplete) && openBrowser(start.verificationUriComplete);
101
+ console.log('');
102
+ console.log(opened ? 'Opened your browser to approve this connection.' : 'Open this link to approve:');
103
+ console.log(` ${start.verificationUriComplete}`);
104
+ // The code is the only thing a person can actually check: it proves the
105
+ // request came from this run and not from someone else's.
106
+ console.log(` The page should show the code ${start.userCode}. Check that it matches.`);
107
+ console.log('');
108
+ console.log('Waiting for approval. A store and a sales channel are created if you have none.');
109
+
110
+ const deadline = Date.now() + start.expiresIn * 1000;
111
+ let interval = (start.interval || 5) * 1000;
112
+
113
+ while (Date.now() < deadline) {
114
+ await new Promise((r) => setTimeout(r, interval));
115
+ let result;
116
+ try {
117
+ const res = await fetch(`${API_BASE}/api/device-auth/poll`, {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json' },
120
+ body: JSON.stringify({ deviceCode: start.deviceCode }),
121
+ });
122
+ result = await res.json();
123
+ } catch {
124
+ continue; // A blip mid-poll is not fatal; the deadline is the real bound.
125
+ }
126
+ if (result.status === 'approved' && result.connectionId) {
127
+ writeEnv(result.connectionId);
128
+ console.log('');
129
+ console.log('Connected. The sales channel id is in .env.local.');
130
+ console.log('Start the storefront with: npm run dev');
131
+ return;
132
+ }
133
+ if (result.status === 'denied') {
134
+ console.error('The connection request was declined. Nothing was created.');
135
+ process.exit(1);
136
+ }
137
+ if (result.status === 'expired') {
138
+ console.error('The code expired before it was approved. Run this again.');
139
+ process.exit(1);
140
+ }
141
+ // Polled inside the interval: back off rather than hammering.
142
+ if (result.status === 'slow_down') interval += 1000;
143
+ }
144
+
145
+ console.error('Timed out waiting for approval. Run this again for a fresh code.');
146
+ process.exit(1);
147
+ }
148
+
149
+ main();
@@ -31,6 +31,7 @@ import { GiftCardInput } from '@/ui/cart/gift-card-input';
31
31
  import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
32
32
  import { LoadingSpinner } from '@/ui/shared/loading-spinner';
33
33
  import { useTranslations } from '@/core/lib/translations';
34
+ import { toAddToCartError, type AddToCartError } from '@/core/lib/add-to-cart-error';
34
35
  import { cn } from '@/core/lib/utils';
35
36
  import { isValidCheckoutId } from '@/core/lib/safe-redirect';
36
37
  import { trackBeginCheckout } from '@/core/lib/tracking';
@@ -52,6 +53,7 @@ function CheckoutContent() {
52
53
  const t = useTranslations('checkout');
53
54
  const tc = useTranslations('common');
54
55
  const tr = useTranslations('reservation');
56
+ const tp = useTranslations('productDetail');
55
57
 
56
58
  const [step, setStep] = useState<CheckoutStep>('address');
57
59
  const [checkout, setCheckout] = useState<Checkout | null>(null);
@@ -77,6 +79,7 @@ function CheckoutContent() {
77
79
  const [orderBumps, setOrderBumps] = useState<CheckoutBumpsResponse | null>(null);
78
80
  const [addedBumpIds, setAddedBumpIds] = useState<Set<string>>(new Set());
79
81
  const [bumpLoading, setBumpLoading] = useState<string | null>(null);
82
+ const [bumpError, setBumpError] = useState<AddToCartError | null>(null);
80
83
  const [customFields, setCustomFields] = useState<CheckoutCustomFieldDefinition[]>([]);
81
84
  const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({});
82
85
  const [customFieldsLoading, setCustomFieldsLoading] = useState(false);
@@ -354,6 +357,7 @@ function CheckoutContent() {
354
357
  // Handle bump toggle
355
358
  async function handleBumpToggle(bumpId: string, add: boolean, variantId?: string) {
356
359
  if (!cart?.id || bumpLoading) return;
360
+ setBumpError(null);
357
361
  try {
358
362
  setBumpLoading(bumpId);
359
363
  const client = getClient();
@@ -370,6 +374,10 @@ function CheckoutContent() {
370
374
  }
371
375
  await refreshCart();
372
376
  } catch (err) {
377
+ // A bump is an extra cart line, so it can be refused by the 50-line cap
378
+ // like any other add. Logging alone left the checkbox springing back with
379
+ // no explanation, at the least forgiving point in the flow.
380
+ setBumpError(toAddToCartError(err));
373
381
  console.error('Failed to toggle order bump:', err);
374
382
  } finally {
375
383
  setBumpLoading(null);
@@ -1044,6 +1052,16 @@ function CheckoutContent() {
1044
1052
  loading={bumpLoading === bump.id}
1045
1053
  />
1046
1054
  ))}
1055
+
1056
+ {/*
1057
+ Why the bump was refused. Without it the checkbox just springs
1058
+ back and the shopper is left guessing at checkout.
1059
+ */}
1060
+ {bumpError && (
1061
+ <p className="text-destructive text-xs" role="alert">
1062
+ {bumpError === 'CART_FULL' ? tp('cartFull') : tp('addToCartFailed')}
1063
+ </p>
1064
+ )}
1047
1065
  </div>
1048
1066
  )}
1049
1067