create-brainerce-store 1.81.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.
- package/dist/index.js +226 -3
- package/messages/en.json +5 -5
- package/messages/he.json +5 -5
- package/package.json +1 -1
- package/templates/nextjs/base/scripts/connect.mjs +306 -3
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +2 -1
- package/templates/nextjs/designs/atelier/messages-patch/en.json +5 -5
- package/templates/nextjs/designs/atelier/messages-patch/he.json +5 -5
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +2 -1
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +2 -1
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.
|
|
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 = [];
|
|
@@ -947,6 +1067,72 @@ function openBrowser(url) {
|
|
|
947
1067
|
return false;
|
|
948
1068
|
}
|
|
949
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
|
+
}
|
|
950
1136
|
async function runDeviceFlow(apiBaseUrl, options = {}) {
|
|
951
1137
|
let start;
|
|
952
1138
|
try {
|
|
@@ -997,6 +1183,9 @@ async function runDeviceFlow(apiBaseUrl, options = {}) {
|
|
|
997
1183
|
}
|
|
998
1184
|
if (result.status === "approved" && result.connectionId) {
|
|
999
1185
|
logger.success(" Approved.");
|
|
1186
|
+
if (options.seedProducts?.length) {
|
|
1187
|
+
await runSeed(apiBaseUrl, start.deviceCode, options.seedProducts);
|
|
1188
|
+
}
|
|
1000
1189
|
return { connectionId: result.connectionId, storeId: result.storeId ?? null };
|
|
1001
1190
|
}
|
|
1002
1191
|
if (result.status === "denied") {
|
|
@@ -1091,6 +1280,9 @@ var program = new import_commander.Command();
|
|
|
1091
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(
|
|
1092
1281
|
"--defer-connection",
|
|
1093
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`."
|
|
1094
1286
|
).option(
|
|
1095
1287
|
"--api-url <url>",
|
|
1096
1288
|
"Brainerce API base URL (overrides BRAINERCE_API_URL env, defaults to https://api.brainerce.com)"
|
|
@@ -1113,6 +1305,28 @@ program.name("create-brainerce-store").description("Scaffold a production-ready
|
|
|
1113
1305
|
scaffoldInPlace = true;
|
|
1114
1306
|
}
|
|
1115
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
|
+
}
|
|
1116
1330
|
const explicitApiUrl = (options.apiUrl || process.env.BRAINERCE_API_URL || "").replace(/\/$/, "");
|
|
1117
1331
|
const apiUrlError = validateApiUrl(explicitApiUrl);
|
|
1118
1332
|
if (apiUrlError) {
|
|
@@ -1178,7 +1392,7 @@ program.name("create-brainerce-store").description("Scaffold a production-ready
|
|
|
1178
1392
|
language: language || "en"
|
|
1179
1393
|
};
|
|
1180
1394
|
} else {
|
|
1181
|
-
const approved = await runDeviceFlow(candidateApiUrls[0]);
|
|
1395
|
+
const approved = await runDeviceFlow(candidateApiUrls[0], { seedProducts });
|
|
1182
1396
|
if (!approved) {
|
|
1183
1397
|
process.exit(1);
|
|
1184
1398
|
}
|
|
@@ -1337,7 +1551,16 @@ Your store will be running at http://localhost:3000
|
|
|
1337
1551
|
`
|
|
1338
1552
|
This store is not connected yet. \`connect\` opens one browser approval,
|
|
1339
1553
|
writes the sales channel id into .env.local, and creates a store and a
|
|
1340
|
-
channel for you if you have neither.
|
|
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.`
|
|
1341
1564
|
);
|
|
1342
1565
|
}
|
|
1343
1566
|
logger.info(`
|
package/messages/en.json
CHANGED
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
},
|
|
130
130
|
"reviews": {
|
|
131
131
|
"title": "Reviews",
|
|
132
|
-
"noReviews": "No reviews yet
|
|
132
|
+
"noReviews": "No reviews yet. Be the first to share your experience.",
|
|
133
133
|
"verifiedPurchase": "Verified purchase",
|
|
134
134
|
"loading": "Loading…",
|
|
135
135
|
"signIn": "Sign in",
|
|
@@ -289,7 +289,7 @@
|
|
|
289
289
|
"customFieldsApplying": "Updating…",
|
|
290
290
|
"customFieldsFailed": "Failed to save selections",
|
|
291
291
|
"customFieldsRequired": "Required",
|
|
292
|
-
"customFieldsSelectPlaceholder": "
|
|
292
|
+
"customFieldsSelectPlaceholder": "Select an option",
|
|
293
293
|
"customFieldsImageUpload": "Click to upload an image",
|
|
294
294
|
"customFieldsImageRemove": "Remove",
|
|
295
295
|
"customFieldsImageUploading": "Uploading...",
|
|
@@ -322,7 +322,7 @@
|
|
|
322
322
|
"address": "Address",
|
|
323
323
|
"streetAddress": "Street address",
|
|
324
324
|
"searchingAddress": "Searching…",
|
|
325
|
-
"outsideDeliveryZone": "This address is outside our regular delivery zones. You can still continue
|
|
325
|
+
"outsideDeliveryZone": "This address is outside our regular delivery zones. You can still continue, and we'll confirm delivery by phone.",
|
|
326
326
|
"apartmentSuite": "Apartment, suite, etc.",
|
|
327
327
|
"aptPlaceholder": "Apt, suite, unit, etc. (optional)",
|
|
328
328
|
"city": "City",
|
|
@@ -574,7 +574,7 @@
|
|
|
574
574
|
"placeholder": "your@email.com",
|
|
575
575
|
"submit": "Subscribe",
|
|
576
576
|
"submitting": "Sending...",
|
|
577
|
-
"checkEmailTitle": "Almost there
|
|
577
|
+
"checkEmailTitle": "Almost there. Check your email",
|
|
578
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.",
|
|
579
579
|
"offerPercent": "{value}% off your first order",
|
|
580
580
|
"offerAmount": "{value} off your first order",
|
|
@@ -618,6 +618,6 @@
|
|
|
618
618
|
"locked": "Payment has already started, so this order can no longer take a gift card.",
|
|
619
619
|
"lockedHint": "A gift card is added before the payment step.",
|
|
620
620
|
"removeFailed": "We could not remove that gift card.",
|
|
621
|
-
"applied": "Gift card applied
|
|
621
|
+
"applied": "Gift card applied for {amount}"
|
|
622
622
|
}
|
|
623
623
|
}
|
package/messages/he.json
CHANGED
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
},
|
|
130
130
|
"reviews": {
|
|
131
131
|
"title": "ביקורות",
|
|
132
|
-
"noReviews": "אין עדיין
|
|
132
|
+
"noReviews": "אין עדיין ביקורות. היו הראשונים לשתף.",
|
|
133
133
|
"verifiedPurchase": "רכישה מאומתת",
|
|
134
134
|
"loading": "טוען…",
|
|
135
135
|
"signIn": "התחברו",
|
|
@@ -289,7 +289,7 @@
|
|
|
289
289
|
"customFieldsApplying": "מעדכן…",
|
|
290
290
|
"customFieldsFailed": "שגיאה בשמירת הבחירות",
|
|
291
291
|
"customFieldsRequired": "חובה",
|
|
292
|
-
"customFieldsSelectPlaceholder": "
|
|
292
|
+
"customFieldsSelectPlaceholder": "בחר אפשרות",
|
|
293
293
|
"customFieldsImageUpload": "לחץ להעלאת תמונה",
|
|
294
294
|
"customFieldsImageRemove": "הסר",
|
|
295
295
|
"customFieldsImageUploading": "...מעלה",
|
|
@@ -322,7 +322,7 @@
|
|
|
322
322
|
"address": "כתובת",
|
|
323
323
|
"streetAddress": "רחוב ומספר",
|
|
324
324
|
"searchingAddress": "מחפש…",
|
|
325
|
-
"outsideDeliveryZone": "הכתובת הזו מחוץ לאזורי המשלוח הרגילים שלנו. ניתן
|
|
325
|
+
"outsideDeliveryZone": "הכתובת הזו מחוץ לאזורי המשלוח הרגילים שלנו. ניתן להמשיך, וניצור איתך קשר טלפוני לתיאום המשלוח.",
|
|
326
326
|
"apartmentSuite": "דירה, קומה וכו׳",
|
|
327
327
|
"aptPlaceholder": "דירה, קומה, כניסה (אופציונלי)",
|
|
328
328
|
"city": "עיר",
|
|
@@ -574,7 +574,7 @@
|
|
|
574
574
|
"placeholder": "your@email.com",
|
|
575
575
|
"submit": "הרשמה",
|
|
576
576
|
"submitting": "שולח...",
|
|
577
|
-
"checkEmailTitle": "כמעט
|
|
577
|
+
"checkEmailTitle": "כמעט סיימנו. בדקו את המייל",
|
|
578
578
|
"checkEmailBody": "שלחנו לכם קישור לאישור. בדקו גם בתיקיית הספאם; עותק נוסף לא יישלח ב-24 השעות הקרובות.",
|
|
579
579
|
"offerPercent": "{value}% הנחה על ההזמנה הראשונה",
|
|
580
580
|
"offerAmount": "{value} הנחה על ההזמנה הראשונה",
|
|
@@ -618,6 +618,6 @@
|
|
|
618
618
|
"locked": "התשלום כבר התחיל, אז אי אפשר להוסיף כרטיס מתנה להזמנה הזאת.",
|
|
619
619
|
"lockedHint": "כרטיס מתנה מוסיפים לפני שלב התשלום.",
|
|
620
620
|
"removeFailed": "לא הצלחנו להסיר את כרטיס המתנה.",
|
|
621
|
-
"applied": "כרטיס מתנה
|
|
621
|
+
"applied": "כרטיס מתנה בסך {amount}"
|
|
622
622
|
}
|
|
623
623
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Connect this storefront to a Brainerce sales channel
|
|
3
|
+
* Connect this storefront to a Brainerce sales channel, and optionally fill it
|
|
4
|
+
* with a starter catalog.
|
|
4
5
|
*
|
|
5
6
|
* Run it when the build is finished, not before:
|
|
6
7
|
*
|
|
7
8
|
* npm run connect
|
|
9
|
+
* npm run connect -- --seed-products '[{"name":"Burr Grinder","basePrice":389}]'
|
|
8
10
|
*
|
|
9
11
|
* WHY THIS IS A SEPARATE STEP. Nothing in a storefront's code depends on WHICH
|
|
10
12
|
* store it points at — the pages, the routing, the cart, the checkout and every
|
|
@@ -13,6 +15,14 @@
|
|
|
13
15
|
* uninterrupted and comes here at the end, instead of stopping for an approval
|
|
14
16
|
* before a single file exists.
|
|
15
17
|
*
|
|
18
|
+
* ⛔ WHY SEEDING LIVES HERE TOO. The approval leaves a short single-use grant on
|
|
19
|
+
* /api/device-auth/seed keyed to the device code, and this script is the only
|
|
20
|
+
* thing that holds that code. Seeding from here is also strictly better than
|
|
21
|
+
* seeding at scaffold time: by now the storefront exists, so the products can
|
|
22
|
+
* match what was actually built rather than what was guessed before any file
|
|
23
|
+
* was written. Without this the deferred path could only ever produce an empty
|
|
24
|
+
* store, and the merchant's first look at their own site is a blank grid.
|
|
25
|
+
*
|
|
16
26
|
* One click in a browser, and this writes the id into `.env.local`. A store and
|
|
17
27
|
* a sales channel are created for you if you have neither.
|
|
18
28
|
*
|
|
@@ -28,6 +38,169 @@ const API_BASE = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com').
|
|
|
28
38
|
const ENV_PATH = resolve(process.cwd(), '.env.local');
|
|
29
39
|
const KEYS = ['NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID', 'NEXT_PUBLIC_BRAINERCE_CONNECTION_ID'];
|
|
30
40
|
|
|
41
|
+
// The seed route's own limits, mirrored here so a bad payload is refused before
|
|
42
|
+
// the grant is touched.
|
|
43
|
+
//
|
|
44
|
+
// ⛔ THIS IS A HAND-KEPT COPY. The canonical one is
|
|
45
|
+
// `packages/cli-shared/src/seed-products.ts`, which the scaffolder itself
|
|
46
|
+
// imports; this file cannot, because it ships standalone into the user's
|
|
47
|
+
// project where that package does not exist. When a limit moves, it moves
|
|
48
|
+
// there first and this follows. All of it mirrors SeedDeviceAuthDto /
|
|
49
|
+
// SeedProductDto in apps/backend/src/modules/device-auth/dto/device-auth.dto.ts,
|
|
50
|
+
// which is what actually enforces them.
|
|
51
|
+
const SEED_MIN_PRODUCTS = 1;
|
|
52
|
+
const SEED_MAX_PRODUCTS = 20;
|
|
53
|
+
const SEED_MAX_NAME = 191;
|
|
54
|
+
const SEED_MAX_DESCRIPTION = 5000;
|
|
55
|
+
const SEED_FIELDS = ['name', 'basePrice', 'description'];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Read `--seed-products <json>` from argv, or `BRAINERCE_SEED_PRODUCTS`.
|
|
59
|
+
*
|
|
60
|
+
* The environment variable is not a nicety: `npm run connect -- --seed-products '[...]'`
|
|
61
|
+
* loses its quoting in PowerShell, which is the default shell for a large share
|
|
62
|
+
* of the agents that run this, and a JSON array mangled into eight arguments
|
|
63
|
+
* fails in a way nobody can read. The variable is the escape hatch for those.
|
|
64
|
+
*/
|
|
65
|
+
function readSeedArg(argv) {
|
|
66
|
+
const flag = argv.indexOf('--seed-products');
|
|
67
|
+
if (flag !== -1) return argv[flag + 1] ?? '';
|
|
68
|
+
const inline = argv.find((a) => a.startsWith('--seed-products='));
|
|
69
|
+
if (inline) return inline.slice('--seed-products='.length);
|
|
70
|
+
return process.env.BRAINERCE_SEED_PRODUCTS ?? null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Parse and check the seed payload locally, before anything is sent.
|
|
75
|
+
*
|
|
76
|
+
* The seeding grant is single use and dies with the approval window, so a
|
|
77
|
+
* payload the API would reject must never reach it: there is no second attempt
|
|
78
|
+
* inside the run, and a second attempt would cost the user a second approval.
|
|
79
|
+
* Every rule here is one the backend enforces anyway — this only moves the
|
|
80
|
+
* refusal to a point where it is still free.
|
|
81
|
+
*
|
|
82
|
+
* The backend runs `forbidNonWhitelisted`, so ONE stray key (`sku`, `type`,
|
|
83
|
+
* `images` are the ones agents reach for) rejects the entire request rather
|
|
84
|
+
* than the row that carried it. That is why unknown keys are an error here and
|
|
85
|
+
* not something quietly dropped.
|
|
86
|
+
*
|
|
87
|
+
* Returns `{ products }` or `{ error }`. Pure: no I/O, no process state.
|
|
88
|
+
*/
|
|
89
|
+
function parseSeedProducts(raw) {
|
|
90
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
91
|
+
return {
|
|
92
|
+
error:
|
|
93
|
+
'--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.',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(raw);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return {
|
|
102
|
+
error: `--seed-products is not valid JSON (${error.message}). Pass the array as a single quoted argument, or put it in BRAINERCE_SEED_PRODUCTS if your shell keeps breaking the quoting.`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A bare array is the documented shape. An object with a `products` key is
|
|
107
|
+
// the body the seed route itself takes, and an agent copying from the route
|
|
108
|
+
// reference will send that, so accept it rather than refusing a payload that
|
|
109
|
+
// is right in every way that matters.
|
|
110
|
+
const list = Array.isArray(parsed)
|
|
111
|
+
? parsed
|
|
112
|
+
: parsed && typeof parsed === 'object' && Array.isArray(parsed.products)
|
|
113
|
+
? parsed.products
|
|
114
|
+
: null;
|
|
115
|
+
|
|
116
|
+
if (!list) {
|
|
117
|
+
return {
|
|
118
|
+
error:
|
|
119
|
+
'--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.',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (list.length < SEED_MIN_PRODUCTS) {
|
|
123
|
+
return {
|
|
124
|
+
error:
|
|
125
|
+
'--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.',
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (list.length > SEED_MAX_PRODUCTS) {
|
|
129
|
+
return {
|
|
130
|
+
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.`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const products = [];
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < list.length; i += 1) {
|
|
137
|
+
const entry = list[i];
|
|
138
|
+
const at = `--seed-products product ${i + 1}`;
|
|
139
|
+
|
|
140
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
141
|
+
return {
|
|
142
|
+
error: `${at} is not an object. Each entry looks like {"name":"...","basePrice":0}.`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const unknown = Object.keys(entry).filter((k) => !SEED_FIELDS.includes(k));
|
|
147
|
+
if (unknown.length > 0) {
|
|
148
|
+
return {
|
|
149
|
+
error: `${at} carries ${unknown.map((k) => `"${k}"`).join(', ')}, which the seed route does not accept and will not ignore — one unknown key rejects the whole request. Only ${SEED_FIELDS.join(', ')} are allowed: no sku, no type, no options or variants, no categories, no images. Every seeded product lands as a simple, active product.`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (typeof entry.name !== 'string' || entry.name.trim() === '') {
|
|
154
|
+
return { error: `${at} has no usable "name". It must be a non-empty string.` };
|
|
155
|
+
}
|
|
156
|
+
if (entry.name.length > SEED_MAX_NAME) {
|
|
157
|
+
return {
|
|
158
|
+
error: `${at} ("${entry.name.slice(0, 40)}...") has a name of ${entry.name.length} characters and the limit is ${SEED_MAX_NAME}.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!('basePrice' in entry)) {
|
|
163
|
+
return {
|
|
164
|
+
error: `${at} ("${entry.name}") has no "basePrice". Every seeded product needs one, as a number: {"name":"${entry.name}","basePrice":0} is the minimum shape.`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (typeof entry.basePrice === 'string') {
|
|
168
|
+
// The failure that actually happens: a price quoted as a string. The API
|
|
169
|
+
// validates with @IsNumber(), which does not coerce, so "149" is refused.
|
|
170
|
+
return {
|
|
171
|
+
error: `${at} ("${entry.name}") has a "basePrice" quoted as text. Send ${entry.basePrice.trim() === '' ? '149' : entry.basePrice} without the quotes — the API does not coerce a quoted price, it rejects it.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (typeof entry.basePrice !== 'number' || !Number.isFinite(entry.basePrice)) {
|
|
175
|
+
return {
|
|
176
|
+
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.`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (entry.basePrice < 0) {
|
|
180
|
+
return { error: `${at} ("${entry.name}") has a negative "basePrice".` };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if ('description' in entry) {
|
|
184
|
+
if (typeof entry.description !== 'string') {
|
|
185
|
+
return {
|
|
186
|
+
error: `${at} ("${entry.name}") has a "description" that is not a string. Omit the key entirely rather than sending null.`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (entry.description.length > SEED_MAX_DESCRIPTION) {
|
|
190
|
+
return {
|
|
191
|
+
error: `${at} ("${entry.name}") has a description of ${entry.description.length} characters and the limit is ${SEED_MAX_DESCRIPTION}.`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const product = { name: entry.name, basePrice: entry.basePrice };
|
|
197
|
+
if ('description' in entry) product.description = entry.description;
|
|
198
|
+
products.push(product);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { products };
|
|
202
|
+
}
|
|
203
|
+
|
|
31
204
|
/** Only ever hand the OS a URL on the host we asked. It arrives in a network
|
|
32
205
|
* response, so it is untrusted input on the way to a process launch. */
|
|
33
206
|
function openable(value) {
|
|
@@ -71,11 +244,134 @@ function writeEnv(connectionId) {
|
|
|
71
244
|
writeFileSync(ENV_PATH, body.startsWith('\n') ? body.slice(1) : body);
|
|
72
245
|
}
|
|
73
246
|
|
|
247
|
+
async function readError(res) {
|
|
248
|
+
try {
|
|
249
|
+
const body = await res.json();
|
|
250
|
+
const message = body?.message;
|
|
251
|
+
return Array.isArray(message) ? message.join('; ') : (message ?? `HTTP ${res.status}`);
|
|
252
|
+
} catch {
|
|
253
|
+
return `HTTP ${res.status}`;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Spend the seeding grant.
|
|
259
|
+
*
|
|
260
|
+
* ⛔ `deviceCode` is a 256-bit bearer credential and it stops here. It is the
|
|
261
|
+
* poll credential as well as the seed credential, so anything that put it on
|
|
262
|
+
* stdout or in a log would hand a reader inside the ten-minute window the
|
|
263
|
+
* ability to seed this store. It is never printed and never interpolated into
|
|
264
|
+
* an error string.
|
|
265
|
+
*
|
|
266
|
+
* Called once, with no retry. The route allows 5 requests a minute and the
|
|
267
|
+
* grant is single use, so a retry after an ambiguous failure would report "that
|
|
268
|
+
* window has closed" over a write that may well have landed.
|
|
269
|
+
*/
|
|
270
|
+
async function runSeed(deviceCode, products) {
|
|
271
|
+
let res;
|
|
272
|
+
try {
|
|
273
|
+
res = await fetch(`${API_BASE}/api/device-auth/seed`, {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
headers: { 'Content-Type': 'application/json' },
|
|
276
|
+
body: JSON.stringify({ deviceCode, products }),
|
|
277
|
+
});
|
|
278
|
+
} catch (error) {
|
|
279
|
+
console.error('');
|
|
280
|
+
console.error(` Could not reach the seeding endpoint (${error.message}).`);
|
|
281
|
+
console.error(
|
|
282
|
+
' The request may or may not have landed, so do not claim either way: check the catalog'
|
|
283
|
+
);
|
|
284
|
+
console.error(
|
|
285
|
+
' in the dashboard and add what is missing there. Do not run connect again to retry.'
|
|
286
|
+
);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!res.ok) {
|
|
291
|
+
const message = await readError(res);
|
|
292
|
+
// A 400 from the route's own guard means the grant is gone. A 400 from the
|
|
293
|
+
// validation pipe means the controller never ran and the grant is intact —
|
|
294
|
+
// different facts, and telling the user the window closed when it has not
|
|
295
|
+
// is its own kind of wrong.
|
|
296
|
+
const windowClosed = message.startsWith('That seeding window has closed');
|
|
297
|
+
console.error('');
|
|
298
|
+
if (windowClosed) {
|
|
299
|
+
console.error(' The seeding window is closed — the grant was already spent, or more than');
|
|
300
|
+
console.error(' ten minutes passed since the approval. The connection itself is fine.');
|
|
301
|
+
console.error(' Add products in the dashboard; do not run connect again just to seed.');
|
|
302
|
+
} else {
|
|
303
|
+
console.error(` The seed request was refused before the grant was touched: ${message}`);
|
|
304
|
+
console.error(' The store is connected and still empty. Report this message as written and');
|
|
305
|
+
console.error(' add the products in the dashboard rather than retrying.');
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let body;
|
|
311
|
+
try {
|
|
312
|
+
body = await res.json();
|
|
313
|
+
} catch {
|
|
314
|
+
console.error('');
|
|
315
|
+
console.error(' The seed endpoint answered with something that is not JSON. Products may');
|
|
316
|
+
console.error(' have been created. Check the catalog in the dashboard rather than assuming');
|
|
317
|
+
console.error(' either outcome.');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const created = Array.isArray(body.created) ? body.created : [];
|
|
322
|
+
const failed = Array.isArray(body.failed) ? body.failed : [];
|
|
323
|
+
|
|
324
|
+
// Both halves, always. A per-product failure does not abort the rest, so a
|
|
325
|
+
// partial write is a normal outcome — and reporting one as a clean win is how
|
|
326
|
+
// a merchant finds three of five products missing a week later.
|
|
327
|
+
console.log('');
|
|
328
|
+
if (created.length > 0) {
|
|
329
|
+
console.log(` Seeded ${created.length} product(s): ${created.map((p) => p.name).join(', ')}`);
|
|
330
|
+
}
|
|
331
|
+
for (const f of failed) {
|
|
332
|
+
console.log(` Not created: ${f.name} — ${f.reason}`);
|
|
333
|
+
}
|
|
334
|
+
if (failed.length > 0) {
|
|
335
|
+
console.log('');
|
|
336
|
+
console.log(
|
|
337
|
+
` ${created.length} of ${products.length} product(s) went in; ${failed.length} refused.`
|
|
338
|
+
);
|
|
339
|
+
console.log(
|
|
340
|
+
' Report BOTH halves by name. The window is now spent, so the refused ones have to be'
|
|
341
|
+
);
|
|
342
|
+
console.log(' added in the dashboard.');
|
|
343
|
+
} else if (created.length > 0) {
|
|
344
|
+
console.log(' Anything more comes from the dashboard, or Apps > Browse > Migration Tool.');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
74
348
|
async function main() {
|
|
349
|
+
const seedRaw = readSeedArg(process.argv.slice(2));
|
|
350
|
+
let seedProducts = null;
|
|
351
|
+
if (seedRaw !== null) {
|
|
352
|
+
const parsed = parseSeedProducts(seedRaw);
|
|
353
|
+
if (parsed.error) {
|
|
354
|
+
// Refuse before the approval, not after: a payload rejected on the other
|
|
355
|
+
// side of a click has already cost the user the click.
|
|
356
|
+
console.error(parsed.error);
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
seedProducts = parsed.products;
|
|
360
|
+
}
|
|
361
|
+
|
|
75
362
|
if (process.env.BRAINERCE_CONNECTION_ID) {
|
|
76
363
|
// Already known (CI, or a channel someone created by hand). Nothing to approve.
|
|
77
364
|
writeEnv(process.env.BRAINERCE_CONNECTION_ID.trim());
|
|
78
365
|
console.log('Wrote BRAINERCE_CONNECTION_ID from the environment into .env.local.');
|
|
366
|
+
if (seedProducts) {
|
|
367
|
+
// ⛔ Seeding is authenticated by the device code from an approval. There is
|
|
368
|
+
// no approval on this path, so there is nothing to seed with — say so
|
|
369
|
+
// rather than connecting and silently dropping the catalog.
|
|
370
|
+
console.error('');
|
|
371
|
+
console.error(' Seeding needs a browser approval and this run already had a connection id,');
|
|
372
|
+
console.error(' so no products were created. Add them in the dashboard, or in an agent');
|
|
373
|
+
console.error(' session use the Brainerce MCP server to create products directly.');
|
|
374
|
+
}
|
|
79
375
|
return;
|
|
80
376
|
}
|
|
81
377
|
|
|
@@ -97,9 +393,12 @@ async function main() {
|
|
|
97
393
|
process.exit(1);
|
|
98
394
|
}
|
|
99
395
|
|
|
100
|
-
const opened =
|
|
396
|
+
const opened =
|
|
397
|
+
openable(start.verificationUriComplete) && openBrowser(start.verificationUriComplete);
|
|
101
398
|
console.log('');
|
|
102
|
-
console.log(
|
|
399
|
+
console.log(
|
|
400
|
+
opened ? 'Opened your browser to approve this connection.' : 'Open this link to approve:'
|
|
401
|
+
);
|
|
103
402
|
console.log(` ${start.verificationUriComplete}`);
|
|
104
403
|
// The code is the only thing a person can actually check: it proves the
|
|
105
404
|
// request came from this run and not from someone else's.
|
|
@@ -124,9 +423,13 @@ async function main() {
|
|
|
124
423
|
continue; // A blip mid-poll is not fatal; the deadline is the real bound.
|
|
125
424
|
}
|
|
126
425
|
if (result.status === 'approved' && result.connectionId) {
|
|
426
|
+
// Write the id BEFORE seeding. Seeding is the part that can fail, and a
|
|
427
|
+
// failed catalog must never cost the connection that already succeeded.
|
|
127
428
|
writeEnv(result.connectionId);
|
|
128
429
|
console.log('');
|
|
129
430
|
console.log('Connected. The sales channel id is in .env.local.');
|
|
431
|
+
if (seedProducts) await runSeed(start.deviceCode, seedProducts);
|
|
432
|
+
console.log('');
|
|
130
433
|
console.log('Start the storefront with: npm run dev');
|
|
131
434
|
return;
|
|
132
435
|
}
|
|
@@ -74,7 +74,8 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
74
74
|
// than the inventory left) and PRODUCT_UNAVAILABLE (the line cannot be
|
|
75
75
|
// bought at all any more). Both get their own sentence; everything else
|
|
76
76
|
// is a blip worth one more tap. The 50-line cart cap is NOT a cause here
|
|
77
|
-
// at all, it only ever refuses a brand new line.
|
|
77
|
+
// at all, it only ever refuses a brand new line. Both codes are part of
|
|
78
|
+
// the documented API error catalogue, not guesses. Keep the log: the
|
|
78
79
|
// shopper gets the sentence, the developer still needs the detail.
|
|
79
80
|
const code = getErrorCode(err);
|
|
80
81
|
setLineError(
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"home": {
|
|
10
10
|
"heroEyebrow": "New collection · Handcrafted",
|
|
11
11
|
"heroTitle": "Pieces that tell your story",
|
|
12
|
-
"heroLead": "Thoughtfully designed
|
|
12
|
+
"heroLead": "Thoughtfully designed and handcrafted with love, made to be with you through the small moments and the big ones.",
|
|
13
13
|
"heroCtaSecondary": "Browse categories",
|
|
14
14
|
"heroTrust": "Secure checkout on every order",
|
|
15
15
|
"benefit1Title": "Delivery",
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"benefit4Title": "Returns",
|
|
22
22
|
"benefit4Desc": "See our returns policy for details",
|
|
23
23
|
"featuredEyebrow": "Our picks",
|
|
24
|
-
"featuredSubtitle": "The pieces our customers love most
|
|
24
|
+
"featuredSubtitle": "The pieces our customers love most, hand-picked from the new collection.",
|
|
25
25
|
"categoriesEyebrow": "Categories",
|
|
26
26
|
"categoriesTitle": "Find the perfect piece",
|
|
27
|
-
"categoriesSubtitle": "From everyday favorites to statement pieces
|
|
27
|
+
"categoriesSubtitle": "From everyday favorites to statement pieces, there's something here for everyone.",
|
|
28
28
|
"catRings": "Rings",
|
|
29
29
|
"catNecklaces": "Necklaces",
|
|
30
30
|
"catEarrings": "Earrings",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"heroEmptyHint": "Your first published products will appear here"
|
|
53
53
|
},
|
|
54
54
|
"products": {
|
|
55
|
-
"listingSubtitle": "Our full collection
|
|
55
|
+
"listingSubtitle": "Our full collection, handmade from genuine materials with love for detail.",
|
|
56
56
|
"filtersLabel": "Filter",
|
|
57
57
|
"emptyTitle": "No results found",
|
|
58
58
|
"emptyBody": "Try clearing the filters or searching for something else.",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"shippingAtCheckout": "Shipping calculated at checkout"
|
|
85
85
|
},
|
|
86
86
|
"footer": {
|
|
87
|
-
"tagline": "{{storeName}}
|
|
87
|
+
"tagline": "{{storeName}}, handcrafted with love in Israel.",
|
|
88
88
|
"followUs": "Follow us",
|
|
89
89
|
"quickLinksTitle": "Quick links",
|
|
90
90
|
"linkProducts": "All products",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"home": {
|
|
10
10
|
"heroEyebrow": "קולקציה חדשה · עבודת יד",
|
|
11
11
|
"heroTitle": "פריטים שמספרים את הסיפור שלך",
|
|
12
|
-
"heroLead": "עיצובים עדינים, בעבודת יד
|
|
12
|
+
"heroLead": "עיצובים עדינים, בעבודת יד ובאהבה, שנוצרו ללוות אתכן ברגעים הקטנים והגדולים.",
|
|
13
13
|
"heroCtaSecondary": "לצפייה בקטגוריות",
|
|
14
14
|
"heroTrust": "קופה מאובטחת בכל הזמנה",
|
|
15
15
|
"benefit1Title": "משלוח",
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"benefit4Title": "החזרות",
|
|
22
22
|
"benefit4Desc": "הפרטים במדיניות ההחזרות שלנו",
|
|
23
23
|
"featuredEyebrow": "הנבחרים שלנו",
|
|
24
|
-
"featuredSubtitle": "הפריטים שהלקוחות שלנו הכי
|
|
24
|
+
"featuredSubtitle": "הפריטים שהלקוחות שלנו הכי אוהבות, שנבחרו בקפידה מהקולקציה החדשה.",
|
|
25
25
|
"categoriesEyebrow": "קטגוריות",
|
|
26
26
|
"categoriesTitle": "מצאו את הפריט המושלם",
|
|
27
|
-
"categoriesSubtitle": "מהפריטים העדינים ועד
|
|
27
|
+
"categoriesSubtitle": "מהפריטים העדינים ועד הבולטים, לכל אחת יש את השפה שלה.",
|
|
28
28
|
"catRings": "טבעות",
|
|
29
29
|
"catNecklaces": "שרשראות",
|
|
30
30
|
"catEarrings": "עגילים",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"heroEmptyHint": "המוצרים הראשונים שתפרסמו לחנות יופיעו כאן"
|
|
53
53
|
},
|
|
54
54
|
"products": {
|
|
55
|
-
"listingSubtitle": "הקולקציה המלאה
|
|
55
|
+
"listingSubtitle": "הקולקציה המלאה שלנו, בעבודת יד, מחומרים אמיתיים ומתוך אהבה לפרטים.",
|
|
56
56
|
"filtersLabel": "סינון",
|
|
57
57
|
"emptyTitle": "לא מצאנו תוצאות",
|
|
58
58
|
"emptyBody": "נסו לנקות את הסינון או לחפש משהו אחר.",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"shippingAtCheckout": "משלוח יחושב בקופה"
|
|
85
85
|
},
|
|
86
86
|
"footer": {
|
|
87
|
-
"tagline": "{{storeName}}
|
|
87
|
+
"tagline": "{{storeName}}, עבודת יד ועיצוב באהבה בישראל.",
|
|
88
88
|
"followUs": "עקבו אחרינו",
|
|
89
89
|
"quickLinksTitle": "ניווט מהיר",
|
|
90
90
|
"linkProducts": "כל המוצרים",
|
|
@@ -78,7 +78,8 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
78
78
|
// than the inventory left) and PRODUCT_UNAVAILABLE (the line cannot be
|
|
79
79
|
// bought at all any more). Both get their own sentence; everything else
|
|
80
80
|
// is a blip worth one more tap. The 50-line cart cap is NOT a cause here
|
|
81
|
-
// at all, it only ever refuses a brand new line.
|
|
81
|
+
// at all, it only ever refuses a brand new line. Both codes are part of
|
|
82
|
+
// the documented API error catalogue, not guesses. Keep the log: the
|
|
82
83
|
// shopper gets the sentence, the developer still needs the detail.
|
|
83
84
|
const code = getErrorCode(err);
|
|
84
85
|
setLineError(
|
|
@@ -78,7 +78,8 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
78
78
|
// than the inventory left) and PRODUCT_UNAVAILABLE (the line cannot be
|
|
79
79
|
// bought at all any more). Both get their own sentence; everything else
|
|
80
80
|
// is a blip worth one more tap. The 50-line cart cap is NOT a cause here
|
|
81
|
-
// at all, it only ever refuses a brand new line.
|
|
81
|
+
// at all, it only ever refuses a brand new line. Both codes are part of
|
|
82
|
+
// the documented API error catalogue, not guesses. Keep the log: the
|
|
82
83
|
// shopper gets the sentence, the developer still needs the detail.
|
|
83
84
|
const code = getErrorCode(err);
|
|
84
85
|
setLineError(
|