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.
- package/dist/index.js +152 -9
- package/messages/en.json +8 -1
- package/messages/he.json +8 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.eslintrc.json +2 -0
- package/templates/nextjs/base/AI-GUIDE.md +32 -2
- package/templates/nextjs/base/package.json.ejs +53 -52
- package/templates/nextjs/base/scripts/connect.mjs +149 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +18 -0
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +22 -0
- package/templates/nextjs/base/src/core/lib/add-to-cart-error.ts +49 -0
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +18 -0
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +49 -0
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +96 -2
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +17 -0
- package/templates/nextjs/base/src/ui/product/product-card.tsx +17 -0
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +13 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +18 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +49 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +101 -5
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +17 -0
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +17 -0
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +13 -0
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +16 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +45 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +95 -2
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +15 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +15 -0
- 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.
|
|
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
|
|
1062
|
-
if (
|
|
1063
|
-
logger.error(
|
|
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
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
@@ -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`,
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"react
|
|
17
|
-
"
|
|
18
|
-
"@radix-ui/react-
|
|
19
|
-
"@radix-ui/react-
|
|
20
|
-
"@radix-ui/react-
|
|
21
|
-
"@radix-ui/react-
|
|
22
|
-
"@radix-ui/react-
|
|
23
|
-
"@radix-ui/react-
|
|
24
|
-
"@radix-ui/react-
|
|
25
|
-
"@radix-ui/react-
|
|
26
|
-
"@radix-ui/react-
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"@types/
|
|
38
|
-
"@types/react
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"eslint
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|