create-brainerce-store 1.80.0 → 1.82.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 (31) hide show
  1. package/dist/index.js +375 -9
  2. package/messages/en.json +13 -6
  3. package/messages/he.json +13 -6
  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 +452 -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 +50 -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/messages-patch/en.json +5 -5
  19. package/templates/nextjs/designs/atelier/messages-patch/he.json +5 -5
  20. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +18 -0
  21. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +50 -0
  22. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +101 -5
  23. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +17 -0
  24. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +17 -0
  25. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +13 -0
  26. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +16 -0
  27. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +46 -0
  28. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +95 -2
  29. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +15 -0
  30. package/templates/nextjs/ui-canvas/product/product-card.tsx +15 -0
  31. 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.82.0",
35
35
  description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
36
36
  homepage: "https://brainerce.com",
37
37
  repository: {
@@ -330,6 +330,26 @@ var BRAINERCE_RUNTIME_DEPS = Object.freeze({
330
330
  // both to show the merchant's welcome offer beside the field. On an older
331
331
  // SDK a scaffolded store fails type-check at creation, exactly like the KIT
332
332
  // floor above — so this floor is load-bearing, not cosmetic.
333
+ //
334
+ // ⛔⛔ RAISING THIS FLOOR: the version you pin must already be at least SEVEN
335
+ // DAYS OLD on npm, and template code must not call an SDK API until the
336
+ // version carrying it has aged that long.
337
+ //
338
+ // `minimumReleaseAge` — a package-age cooldown that refuses any dependency
339
+ // published within the last week — is now a standard supply-chain defence.
340
+ // pnpm ships the setting (`minimumReleaseAge: 10080`), and ChatGPT Sites
341
+ // enforces seven days on publish. Our SDK cadence is close to daily, so a
342
+ // floor set the day a release lands is a floor that NO cooldown-enforcing
343
+ // environment can install. Measured 2026-09-10: of every 2.x release, only
344
+ // 2.0.2 was over seven days old, and it predates `getBenefit` — which is why
345
+ // a Sites publish against this pin is blocked outright with no same-day fix
346
+ // available on our side, and why lowering the pin would not have helped.
347
+ // The failure is invisible here: `pnpm install` in this repo is happy, and
348
+ // the refusal only appears in someone else's environment, at publish time.
349
+ //
350
+ // So the ordering is: ship the SDK, wait out the week, THEN adopt its API in
351
+ // `templates/` and raise this floor in the same change. A feature is not
352
+ // late for waiting seven days; a template nobody can publish is worse.
333
353
  brainerce: "^2.7.0",
334
354
  "isomorphic-dompurify": "^3.8.0"
335
355
  });
@@ -401,6 +421,106 @@ function detectInvokingPackageManager() {
401
421
  return "npm";
402
422
  }
403
423
 
424
+ // ../cli-shared/src/seed-products.ts
425
+ var SEED_MIN_PRODUCTS = 1;
426
+ var SEED_MAX_PRODUCTS = 20;
427
+ var SEED_MAX_NAME = 191;
428
+ var SEED_MAX_DESCRIPTION = 5e3;
429
+ var SEED_FIELDS = ["name", "basePrice", "description"];
430
+ function parseSeedProducts(raw) {
431
+ if (typeof raw !== "string" || raw.trim() === "") {
432
+ return {
433
+ error: "--seed-products was passed with no value. Give it a JSON array of products, quoted as one shell argument, or drop the flag entirely to connect without seeding."
434
+ };
435
+ }
436
+ let parsed;
437
+ try {
438
+ parsed = JSON.parse(raw);
439
+ } catch (error) {
440
+ return {
441
+ error: `--seed-products is not valid JSON (${error instanceof Error ? error.message : String(error)}). Pass the array as a single quoted argument, or put it in BRAINERCE_SEED_PRODUCTS if your shell keeps breaking the quoting.`
442
+ };
443
+ }
444
+ const list = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && Array.isArray(parsed.products) ? parsed.products : null;
445
+ if (!list) {
446
+ return {
447
+ error: '--seed-products must be a JSON array of products, or an object with a "products" array. A single product object on its own is not accepted; wrap it in an array.'
448
+ };
449
+ }
450
+ if (list.length < SEED_MIN_PRODUCTS) {
451
+ return {
452
+ error: "--seed-products was an empty list. Seeding needs at least one product; if the user has not described what they sell, leave the flag off and build against the empty catalog."
453
+ };
454
+ }
455
+ if (list.length > SEED_MAX_PRODUCTS) {
456
+ return {
457
+ error: `--seed-products carries ${list.length} products and the limit is ${SEED_MAX_PRODUCTS}. This is a starter catalog, not an import: pick the ${SEED_MAX_PRODUCTS} that best show the store, and point the merchant at the dashboard or Apps > Browse > Migration Tool for the rest.`
458
+ };
459
+ }
460
+ const products = [];
461
+ for (let i = 0; i < list.length; i += 1) {
462
+ const entry = list[i];
463
+ const at = `--seed-products product ${i + 1}`;
464
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
465
+ return {
466
+ error: `${at} is not an object. Each entry looks like {"name":"...","basePrice":0}.`
467
+ };
468
+ }
469
+ const unknown = Object.keys(entry).filter(
470
+ (k) => !SEED_FIELDS.includes(k)
471
+ );
472
+ if (unknown.length > 0) {
473
+ return {
474
+ error: `${at} carries ${unknown.map((k) => `"${k}"`).join(", ")}, which the seed route does not accept and will not ignore \u2014 one unknown key rejects the whole request. Only ${SEED_FIELDS.join(
475
+ ", "
476
+ )} are allowed: no sku, no type, no options or variants, no categories, no images. Every seeded product lands as a simple, active product.`
477
+ };
478
+ }
479
+ if (typeof entry.name !== "string" || entry.name.trim() === "") {
480
+ return { error: `${at} has no usable "name". It must be a non-empty string.` };
481
+ }
482
+ if (entry.name.length > SEED_MAX_NAME) {
483
+ return {
484
+ error: `${at} ("${entry.name.slice(0, 40)}...") has a name of ${entry.name.length} characters and the limit is ${SEED_MAX_NAME}.`
485
+ };
486
+ }
487
+ if (!("basePrice" in entry)) {
488
+ return {
489
+ error: `${at} ("${entry.name}") has no "basePrice". Every seeded product needs one, as a number: {"name":"${entry.name}","basePrice":0} is the minimum shape.`
490
+ };
491
+ }
492
+ if (typeof entry.basePrice === "string") {
493
+ return {
494
+ error: `${at} ("${entry.name}") has a "basePrice" quoted as text. Send ${entry.basePrice.trim() === "" ? "149" : entry.basePrice} without the quotes \u2014 the API does not coerce a quoted price, it rejects it.`
495
+ };
496
+ }
497
+ if (typeof entry.basePrice !== "number" || !Number.isFinite(entry.basePrice)) {
498
+ return {
499
+ error: `${at} ("${entry.name}") has a "basePrice" that is not a number. It must be a plain finite number, not null, not a string and not an expression.`
500
+ };
501
+ }
502
+ if (entry.basePrice < 0) {
503
+ return { error: `${at} ("${entry.name}") has a negative "basePrice".` };
504
+ }
505
+ if ("description" in entry) {
506
+ if (typeof entry.description !== "string") {
507
+ return {
508
+ error: `${at} ("${entry.name}") has a "description" that is not a string. Omit the key entirely rather than sending null.`
509
+ };
510
+ }
511
+ if (entry.description.length > SEED_MAX_DESCRIPTION) {
512
+ return {
513
+ error: `${at} ("${entry.name}") has a description of ${entry.description.length} characters and the limit is ${SEED_MAX_DESCRIPTION}.`
514
+ };
515
+ }
516
+ }
517
+ const product = { name: entry.name, basePrice: entry.basePrice };
518
+ if (typeof entry.description === "string") product.description = entry.description;
519
+ products.push(product);
520
+ }
521
+ return { products };
522
+ }
523
+
404
524
  // src/cli.ts
405
525
  async function runInteractive(defaults) {
406
526
  const questions = [];
@@ -914,6 +1034,174 @@ var logger = {
914
1034
  }
915
1035
  };
916
1036
 
1037
+ // src/device-flow.ts
1038
+ var import_node_child_process = require("child_process");
1039
+ function isOpenableUrl(value, apiBaseUrl) {
1040
+ let url;
1041
+ try {
1042
+ url = new URL(value);
1043
+ } catch {
1044
+ return false;
1045
+ }
1046
+ if (url.protocol !== "https:" && url.protocol !== "http:") return false;
1047
+ const apiHost = (() => {
1048
+ try {
1049
+ return new URL(apiBaseUrl).hostname;
1050
+ } catch {
1051
+ return "";
1052
+ }
1053
+ })();
1054
+ const base = apiHost.replace(/^api(-staging)?\./, "");
1055
+ return url.hostname === base || url.hostname.endsWith(`.${base}`);
1056
+ }
1057
+ function openBrowser(url) {
1058
+ const platform = process.platform;
1059
+ const [cmd, args] = platform === "win32" ? ["cmd", ["/c", "start", "", url]] : platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
1060
+ try {
1061
+ const child = (0, import_node_child_process.spawn)(cmd, args, { stdio: "ignore", detached: true });
1062
+ child.on("error", () => {
1063
+ });
1064
+ child.unref();
1065
+ return true;
1066
+ } catch {
1067
+ return false;
1068
+ }
1069
+ }
1070
+ async function readError(res) {
1071
+ try {
1072
+ const body = await res.json();
1073
+ const message = body?.message;
1074
+ return Array.isArray(message) ? message.join("; ") : message ?? `HTTP ${res.status}`;
1075
+ } catch {
1076
+ return `HTTP ${res.status}`;
1077
+ }
1078
+ }
1079
+ async function runSeed(apiBaseUrl, deviceCode, products) {
1080
+ let res;
1081
+ try {
1082
+ res = await fetch(`${apiBaseUrl}/api/device-auth/seed`, {
1083
+ method: "POST",
1084
+ headers: { "Content-Type": "application/json" },
1085
+ body: JSON.stringify({ deviceCode, products })
1086
+ });
1087
+ } catch (error) {
1088
+ logger.error(
1089
+ ` Could not reach the seeding endpoint (${error instanceof Error ? error.message : String(error)}).
1090
+ The request may or may not have landed, so do not claim either way: check the
1091
+ catalog in the dashboard and add what is missing there.`
1092
+ );
1093
+ return;
1094
+ }
1095
+ if (!res.ok) {
1096
+ const message = await readError(res);
1097
+ if (message.startsWith("That seeding window has closed")) {
1098
+ logger.error(
1099
+ " The seeding window is closed \u2014 the grant was already spent, or more than ten\n minutes passed since the approval. The connection itself is fine; add the\n products in the dashboard."
1100
+ );
1101
+ } else {
1102
+ logger.error(
1103
+ ` The seed request was refused before the grant was touched: ${message}
1104
+ The store is connected and still empty. Report this message as written and
1105
+ add the products in the dashboard rather than retrying.`
1106
+ );
1107
+ }
1108
+ return;
1109
+ }
1110
+ let body;
1111
+ try {
1112
+ body = await res.json();
1113
+ } catch {
1114
+ logger.error(
1115
+ " The seed endpoint answered with something that is not JSON. Products may have\n been created. Check the catalog in the dashboard rather than assuming either way."
1116
+ );
1117
+ return;
1118
+ }
1119
+ const created = Array.isArray(body.created) ? body.created : [];
1120
+ const failed = Array.isArray(body.failed) ? body.failed : [];
1121
+ if (created.length > 0) {
1122
+ logger.success(
1123
+ ` Seeded ${created.length} product(s): ${created.map((p) => p.name).join(", ")}`
1124
+ );
1125
+ }
1126
+ for (const f of failed) {
1127
+ logger.info(` Not created: ${f.name} \u2014 ${f.reason}`);
1128
+ }
1129
+ if (failed.length > 0) {
1130
+ logger.info(
1131
+ ` ${created.length} of ${products.length} product(s) went in; ${failed.length} refused.`
1132
+ );
1133
+ logger.info(" The window is now spent, so add the refused ones in the dashboard.");
1134
+ }
1135
+ }
1136
+ async function runDeviceFlow(apiBaseUrl, options = {}) {
1137
+ let start;
1138
+ try {
1139
+ const res = await fetch(`${apiBaseUrl}/api/device-auth/start`, {
1140
+ method: "POST",
1141
+ headers: { "Content-Type": "application/json" },
1142
+ body: JSON.stringify({
1143
+ clientName: options.clientName || process.env.BRAINERCE_CLIENT_NAME || "create-brainerce-store"
1144
+ })
1145
+ });
1146
+ if (!res.ok) {
1147
+ logger.error(
1148
+ `Could not start the browser approval (HTTP ${res.status}).
1149
+ Create a sales channel in the dashboard instead, then re-run with --connection-id vc_xxx.`
1150
+ );
1151
+ return null;
1152
+ }
1153
+ start = await res.json();
1154
+ } catch (error) {
1155
+ logger.error(
1156
+ `Could not reach Brainerce to start the approval: ${error instanceof Error ? error.message : String(error)}`
1157
+ );
1158
+ return null;
1159
+ }
1160
+ const opened = isOpenableUrl(start.verificationUriComplete, apiBaseUrl) && openBrowser(start.verificationUriComplete);
1161
+ console.log();
1162
+ logger.info(
1163
+ opened ? " Opened your browser to approve this connection." : " Open this link to approve this connection:"
1164
+ );
1165
+ console.log(` ${start.verificationUriComplete}`);
1166
+ console.log(` The page should show the code ${start.userCode} \u2014 check that it matches.`);
1167
+ console.log();
1168
+ logger.info(" Waiting for approval. A store and a sales channel are created if you have none.");
1169
+ const deadline = Date.now() + start.expiresIn * 1e3;
1170
+ let interval = (start.interval || 5) * 1e3;
1171
+ while (Date.now() < deadline) {
1172
+ await new Promise((resolve) => setTimeout(resolve, interval));
1173
+ let result;
1174
+ try {
1175
+ const res = await fetch(`${apiBaseUrl}/api/device-auth/poll`, {
1176
+ method: "POST",
1177
+ headers: { "Content-Type": "application/json" },
1178
+ body: JSON.stringify({ deviceCode: start.deviceCode })
1179
+ });
1180
+ result = await res.json();
1181
+ } catch {
1182
+ continue;
1183
+ }
1184
+ if (result.status === "approved" && result.connectionId) {
1185
+ logger.success(" Approved.");
1186
+ if (options.seedProducts?.length) {
1187
+ await runSeed(apiBaseUrl, start.deviceCode, options.seedProducts);
1188
+ }
1189
+ return { connectionId: result.connectionId, storeId: result.storeId ?? null };
1190
+ }
1191
+ if (result.status === "denied") {
1192
+ logger.error(" The connection request was declined. Nothing was created.");
1193
+ return null;
1194
+ }
1195
+ if (result.status === "expired") {
1196
+ logger.error(" The code expired before it was approved. Run the command again.");
1197
+ return null;
1198
+ }
1199
+ if (result.status === "slow_down") interval += 1e3;
1200
+ }
1201
+ logger.error(" Timed out waiting for approval. Run the command again for a fresh code.");
1202
+ return null;
1203
+ }
1204
+
917
1205
  // src/utils/spinner.ts
918
1206
  var import_ora = __toESM(require("ora"));
919
1207
  function createSpinner(text) {
@@ -959,6 +1247,7 @@ async function checkForUpdate(name, current) {
959
1247
  return null;
960
1248
  }
961
1249
  }
1250
+ var DEFERRED_CONNECTION_ID = "vc_REPLACE_ME_RUN_npm_run_connect";
962
1251
  async function resolveStoreInfoOrExit(connectionId, candidateApiUrls) {
963
1252
  const spinner = createSpinner("Fetching store info...");
964
1253
  spinner.start();
@@ -989,6 +1278,12 @@ async function resolveStoreInfoOrExit(connectionId, candidateApiUrls) {
989
1278
  }
990
1279
  var program = new import_commander.Command();
991
1280
  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(
1281
+ "--defer-connection",
1282
+ "Scaffold now and connect later. Skips the browser approval and writes a placeholder id; finish with `npm run connect` inside the project."
1283
+ ).option(
1284
+ "--seed-products <json>",
1285
+ "Starter catalog to create during the browser approval, as a JSON array of {name, basePrice, description?}. Only valid on the run that performs the approval: with --connection-id there is no grant, and with --defer-connection the approval happens later, in `npm run connect --seed-products`."
1286
+ ).option(
992
1287
  "--api-url <url>",
993
1288
  "Brainerce API base URL (overrides BRAINERCE_API_URL env, defaults to https://api.brainerce.com)"
994
1289
  ).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(
@@ -1010,6 +1305,28 @@ program.name("create-brainerce-store").description("Scaffold a production-ready
1010
1305
  scaffoldInPlace = true;
1011
1306
  }
1012
1307
  let connectionId = options.connectionId;
1308
+ let seedProducts;
1309
+ const seedRaw = options.seedProducts ?? process.env.BRAINERCE_SEED_PRODUCTS;
1310
+ if (seedRaw !== void 0) {
1311
+ if (connectionId) {
1312
+ logger.error(
1313
+ "--seed-products needs a browser approval, and --connection-id skips it: a channel\nyou already have carries no seeding grant. Scaffold without --connection-id to\napprove and seed in one step, or add the products in the dashboard."
1314
+ );
1315
+ process.exit(1);
1316
+ }
1317
+ if (options.deferConnection === true) {
1318
+ logger.error(
1319
+ "--seed-products does not belong on a deferred scaffold: the approval happens later,\ninside the project, and that is where the seeding grant is opened. Scaffold as\nnormal, then finish with:\n npm run connect -- --seed-products '[...]'"
1320
+ );
1321
+ process.exit(1);
1322
+ }
1323
+ const parsed = parseSeedProducts(seedRaw);
1324
+ if (parsed.error) {
1325
+ logger.error(parsed.error);
1326
+ process.exit(1);
1327
+ }
1328
+ seedProducts = parsed.products;
1329
+ }
1013
1330
  const explicitApiUrl = (options.apiUrl || process.env.BRAINERCE_API_URL || "").replace(/\/$/, "");
1014
1331
  const apiUrlError = validateApiUrl(explicitApiUrl);
1015
1332
  if (apiUrlError) {
@@ -1058,11 +1375,28 @@ program.name("create-brainerce-store").description("Scaffold a production-ready
1058
1375
  let storeInfo = null;
1059
1376
  let resolvedApiUrl = candidateApiUrls[0];
1060
1377
  if (connectionId) {
1061
- const connError2 = validateConnectionId(connectionId);
1062
- if (connError2) {
1063
- logger.error(connError2);
1378
+ const connError = validateConnectionId(connectionId);
1379
+ if (connError) {
1380
+ logger.error(connError);
1381
+ process.exit(1);
1382
+ }
1383
+ const resolved = await resolveStoreInfoOrExit(connectionId, candidateApiUrls);
1384
+ storeInfo = resolved.info;
1385
+ resolvedApiUrl = resolved.apiBaseUrl;
1386
+ } else if (options.deferConnection === true) {
1387
+ connectionId = DEFERRED_CONNECTION_ID;
1388
+ storeInfo = {
1389
+ name: projectName || "My Store",
1390
+ storeName: projectName || "My Store",
1391
+ currency: "USD",
1392
+ language: language || "en"
1393
+ };
1394
+ } else {
1395
+ const approved = await runDeviceFlow(candidateApiUrls[0], { seedProducts });
1396
+ if (!approved) {
1064
1397
  process.exit(1);
1065
1398
  }
1399
+ connectionId = approved.connectionId;
1066
1400
  const resolved = await resolveStoreInfoOrExit(connectionId, candidateApiUrls);
1067
1401
  storeInfo = resolved.info;
1068
1402
  resolvedApiUrl = resolved.apiBaseUrl;
@@ -1072,6 +1406,16 @@ program.name("create-brainerce-store").description("Scaffold a production-ready
1072
1406
  const storeSlug = storeInfo ? slugify(storeInfo.storeName) : "";
1073
1407
  const projectNameSuggestion = channelSlug || storeSlug || void 0;
1074
1408
  language = language || storeInfo?.language;
1409
+ if (!projectName && !process.stdin.isTTY) {
1410
+ if (!projectNameSuggestion) {
1411
+ logger.error(
1412
+ "No project name given, and no terminal to ask on. Pass one: npm create brainerce-store@latest my-store"
1413
+ );
1414
+ process.exit(1);
1415
+ }
1416
+ projectName = projectNameSuggestion;
1417
+ logger.info(` No project name given. Using "${projectName}", from your store.`);
1418
+ }
1075
1419
  if (!projectName || !connectionId || !language) {
1076
1420
  const answers = await runInteractive({
1077
1421
  projectName,
@@ -1095,12 +1439,14 @@ program.name("create-brainerce-store").description("Scaffold a production-ready
1095
1439
  logger.error(nameError);
1096
1440
  process.exit(1);
1097
1441
  }
1098
- const connError = validateConnectionId(connectionId);
1099
- if (connError) {
1100
- logger.error(connError);
1101
- process.exit(1);
1442
+ if (connectionId !== DEFERRED_CONNECTION_ID) {
1443
+ const connError = validateConnectionId(connectionId);
1444
+ if (connError) {
1445
+ logger.error(connError);
1446
+ process.exit(1);
1447
+ }
1102
1448
  }
1103
- if (!storeInfo) {
1449
+ if (!storeInfo && connectionId !== DEFERRED_CONNECTION_ID) {
1104
1450
  const resolved = await resolveStoreInfoOrExit(connectionId, candidateApiUrls);
1105
1451
  storeInfo = resolved.info;
1106
1452
  resolvedApiUrl = resolved.apiBaseUrl;
@@ -1196,7 +1542,27 @@ Your store will be running at http://localhost:3000
1196
1542
  if (!scaffoldInPlace) {
1197
1543
  logger.step(`cd ${projectName}`);
1198
1544
  }
1545
+ if (connectionId === DEFERRED_CONNECTION_ID) {
1546
+ logger.step(`${pkgManager}${pkgManager === "npm" ? " run" : ""} connect`);
1547
+ }
1199
1548
  logger.step(`${pkgManager}${pkgManager === "npm" ? " run" : ""} dev`);
1549
+ if (connectionId === DEFERRED_CONNECTION_ID) {
1550
+ logger.info(
1551
+ `
1552
+ This store is not connected yet. \`connect\` opens one browser approval,
1553
+ writes the sales channel id into .env.local, and creates a store and a
1554
+ channel for you if you have neither.
1555
+
1556
+ Know what this store sells? Seed the catalog in that same command \u2014 the
1557
+ approval opens a short window that needs no admin key, and it is the only
1558
+ moment it is open:
1559
+
1560
+ ${pkgManager}${pkgManager === "npm" ? " run" : ""} connect -- --seed-products '[{"name":"Burr Grinder","basePrice":389}]'
1561
+
1562
+ (PowerShell mangles that quoting: put the same JSON in BRAINERCE_SEED_PRODUCTS
1563
+ instead.) Without it the store starts empty and products come from the dashboard.`
1564
+ );
1565
+ }
1200
1566
  logger.info(`
1201
1567
  Your store will be running at http://localhost:3000`);
1202
1568
  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",
@@ -127,7 +129,7 @@
127
129
  },
128
130
  "reviews": {
129
131
  "title": "Reviews",
130
- "noReviews": "No reviews yet be the first to share your experience.",
132
+ "noReviews": "No reviews yet. Be the first to share your experience.",
131
133
  "verifiedPurchase": "Verified purchase",
132
134
  "loading": "Loading…",
133
135
  "signIn": "Sign in",
@@ -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",
@@ -282,7 +289,7 @@
282
289
  "customFieldsApplying": "Updating…",
283
290
  "customFieldsFailed": "Failed to save selections",
284
291
  "customFieldsRequired": "Required",
285
- "customFieldsSelectPlaceholder": "Select ",
292
+ "customFieldsSelectPlaceholder": "Select an option",
286
293
  "customFieldsImageUpload": "Click to upload an image",
287
294
  "customFieldsImageRemove": "Remove",
288
295
  "customFieldsImageUploading": "Uploading...",
@@ -315,7 +322,7 @@
315
322
  "address": "Address",
316
323
  "streetAddress": "Street address",
317
324
  "searchingAddress": "Searching…",
318
- "outsideDeliveryZone": "This address is outside our regular delivery zones. You can still continue we'll confirm delivery by phone.",
325
+ "outsideDeliveryZone": "This address is outside our regular delivery zones. You can still continue, and we'll confirm delivery by phone.",
319
326
  "apartmentSuite": "Apartment, suite, etc.",
320
327
  "aptPlaceholder": "Apt, suite, unit, etc. (optional)",
321
328
  "city": "City",
@@ -567,7 +574,7 @@
567
574
  "placeholder": "your@email.com",
568
575
  "submit": "Subscribe",
569
576
  "submitting": "Sending...",
570
- "checkEmailTitle": "Almost there check your email",
577
+ "checkEmailTitle": "Almost there. Check your email",
571
578
  "checkEmailBody": "We've sent you a link to confirm. Look in your spam folder too; you won't get another copy for 24 hours.",
572
579
  "offerPercent": "{value}% off your first order",
573
580
  "offerAmount": "{value} off your first order",
@@ -611,6 +618,6 @@
611
618
  "locked": "Payment has already started, so this order can no longer take a gift card.",
612
619
  "lockedHint": "A gift card is added before the payment step.",
613
620
  "removeFailed": "We could not remove that gift card.",
614
- "applied": "Gift card applied {amount}"
621
+ "applied": "Gift card applied for {amount}"
615
622
  }
616
623
  }
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": "לא זמין",
@@ -127,7 +129,7 @@
127
129
  },
128
130
  "reviews": {
129
131
  "title": "ביקורות",
130
- "noReviews": "אין עדיין ביקורות היו הראשונים לשתף.",
132
+ "noReviews": "אין עדיין ביקורות. היו הראשונים לשתף.",
131
133
  "verifiedPurchase": "רכישה מאומתת",
132
134
  "loading": "טוען…",
133
135
  "signIn": "התחברו",
@@ -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": "תשלום",
@@ -282,7 +289,7 @@
282
289
  "customFieldsApplying": "מעדכן…",
283
290
  "customFieldsFailed": "שגיאה בשמירת הבחירות",
284
291
  "customFieldsRequired": "חובה",
285
- "customFieldsSelectPlaceholder": "בחר ",
292
+ "customFieldsSelectPlaceholder": "בחר אפשרות",
286
293
  "customFieldsImageUpload": "לחץ להעלאת תמונה",
287
294
  "customFieldsImageRemove": "הסר",
288
295
  "customFieldsImageUploading": "...מעלה",
@@ -315,7 +322,7 @@
315
322
  "address": "כתובת",
316
323
  "streetAddress": "רחוב ומספר",
317
324
  "searchingAddress": "מחפש…",
318
- "outsideDeliveryZone": "הכתובת הזו מחוץ לאזורי המשלוח הרגילים שלנו. ניתן להמשיך ניצור איתך קשר טלפוני לתיאום המשלוח.",
325
+ "outsideDeliveryZone": "הכתובת הזו מחוץ לאזורי המשלוח הרגילים שלנו. ניתן להמשיך, וניצור איתך קשר טלפוני לתיאום המשלוח.",
319
326
  "apartmentSuite": "דירה, קומה וכו׳",
320
327
  "aptPlaceholder": "דירה, קומה, כניסה (אופציונלי)",
321
328
  "city": "עיר",
@@ -567,7 +574,7 @@
567
574
  "placeholder": "your@email.com",
568
575
  "submit": "הרשמה",
569
576
  "submitting": "שולח...",
570
- "checkEmailTitle": "כמעט סיימנו בדקו את המייל",
577
+ "checkEmailTitle": "כמעט סיימנו. בדקו את המייל",
571
578
  "checkEmailBody": "שלחנו לכם קישור לאישור. בדקו גם בתיקיית הספאם; עותק נוסף לא יישלח ב-24 השעות הקרובות.",
572
579
  "offerPercent": "{value}% הנחה על ההזמנה הראשונה",
573
580
  "offerAmount": "{value} הנחה על ההזמנה הראשונה",
@@ -611,6 +618,6 @@
611
618
  "locked": "התשלום כבר התחיל, אז אי אפשר להוסיף כרטיס מתנה להזמנה הזאת.",
612
619
  "lockedHint": "כרטיס מתנה מוסיפים לפני שלב התשלום.",
613
620
  "removeFailed": "לא הצלחנו להסיר את כרטיס המתנה.",
614
- "applied": "כרטיס מתנה {amount}"
621
+ "applied": "כרטיס מתנה בסך {amount}"
615
622
  }
616
623
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.80.0",
3
+ "version": "1.82.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,