@blamejs/blamejs-shop 0.0.52 → 0.0.54
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/CHANGELOG.md +4 -0
- package/SECURITY.md +5 -3
- package/lib/analytics.js +400 -0
- package/lib/email.js +264 -0
- package/lib/giftcards.js +410 -0
- package/lib/index.js +4 -0
- package/lib/inventory-receive.js +494 -0
- package/lib/newsletter.js +176 -12
- package/lib/payment.js +193 -13
- package/lib/reviews.js +412 -0
- package/lib/storefront.js +52 -20
- package/lib/tax.js +391 -3
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/SECURITY.md +0 -1
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.4.json +19 -0
- package/lib/webhooks.js +293 -16
- package/lib/wishlist.js +269 -0
- package/package.json +1 -1
package/lib/reviews.js
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.reviews
|
|
4
|
+
* @title Reviews primitive — operator-moderated product ratings
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Customer-submitted star rating + body per product. Every review
|
|
8
|
+
* lands in `pending` state and requires an explicit `publish` (or
|
|
9
|
+
* `reject`) call before it surfaces on the storefront — operators
|
|
10
|
+
* own the moderation policy; the primitive only guards the row
|
|
11
|
+
* shape and the transitions.
|
|
12
|
+
*
|
|
13
|
+
* Author identity is always stored as `customer_id_hash`:
|
|
14
|
+
* - authenticated submissions hash the customer id via
|
|
15
|
+
* `b.crypto.namespaceHash("review-customer", customer_id)` and
|
|
16
|
+
* keep the raw id alongside so "my reviews" reads stay cheap.
|
|
17
|
+
* - anonymous submissions hash the normalised email (`b.guardEmail`
|
|
18
|
+
* at the strict profile, then `toLowerCase().trim()`); the raw
|
|
19
|
+
* address is NEVER persisted.
|
|
20
|
+
*
|
|
21
|
+
* Composes:
|
|
22
|
+
* - `b.guardUuid` — UUID-shape validation for ids
|
|
23
|
+
* - `b.guardEmail` — strict-profile email shape check
|
|
24
|
+
* - `b.crypto.namespaceHash` — deterministic customer hash
|
|
25
|
+
* - `b.uuid.v7` — row id
|
|
26
|
+
* - `b.pagination` — HMAC-tagged tuple cursors for the list APIs
|
|
27
|
+
*
|
|
28
|
+
* Surface:
|
|
29
|
+
* submit({ product_id, customer_id?, customer_email?, rating,
|
|
30
|
+
* title, body, verified_purchase? })
|
|
31
|
+
* → { id, status: "pending" }
|
|
32
|
+
* publish(id) → moderation pass
|
|
33
|
+
* reject(id, reason?) → moderation deny
|
|
34
|
+
* listForProduct(product_id, opts)→ { rows, next_cursor } (published default)
|
|
35
|
+
* summaryForProduct(product_id) → { count, avg_rating, distribution }
|
|
36
|
+
* byCustomer(customer_id_hash, opts) → { rows, next_cursor }
|
|
37
|
+
*
|
|
38
|
+
* Storage:
|
|
39
|
+
* - `reviews` (migration `0011_reviews.sql`).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
var CUSTOMER_NAMESPACE = "review-customer";
|
|
43
|
+
var MAX_TITLE_LEN = 120;
|
|
44
|
+
var MAX_BODY_LEN = 4000;
|
|
45
|
+
var MAX_LIST_LIMIT = 100;
|
|
46
|
+
var DEFAULT_LIMIT = 50;
|
|
47
|
+
var MAX_REJECT_REASON_LEN = 500;
|
|
48
|
+
|
|
49
|
+
var REVIEW_ORDER_KEY = ["created_at:desc", "id:desc"];
|
|
50
|
+
var CUSTOMER_ORDER_KEY = ["created_at:desc", "id:desc"];
|
|
51
|
+
|
|
52
|
+
var ALLOWED_STATUSES = ["pending", "published", "rejected"];
|
|
53
|
+
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
54
|
+
|
|
55
|
+
// Lazy framework handle — matches the pattern used by the rest of
|
|
56
|
+
// the shop primitives; avoids the `require` cycle that would arise
|
|
57
|
+
// from importing `./index` at module-eval time.
|
|
58
|
+
var bShop;
|
|
59
|
+
function _b() {
|
|
60
|
+
if (!bShop) bShop = require("./index");
|
|
61
|
+
return bShop.framework;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- validators ---------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
function _uuid(s, label) {
|
|
67
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
68
|
+
catch (e) { throw new TypeError("reviews: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function _rating(n) {
|
|
72
|
+
if (!Number.isInteger(n) || n < 1 || n > 5) {
|
|
73
|
+
throw new TypeError("reviews: rating must be an integer between 1 and 5");
|
|
74
|
+
}
|
|
75
|
+
return n;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function _title(s) {
|
|
79
|
+
if (typeof s !== "string" || !s.length) {
|
|
80
|
+
throw new TypeError("reviews: title must be a non-empty string");
|
|
81
|
+
}
|
|
82
|
+
if (s.length > MAX_TITLE_LEN) {
|
|
83
|
+
throw new TypeError("reviews: title must be <= " + MAX_TITLE_LEN + " characters");
|
|
84
|
+
}
|
|
85
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
86
|
+
throw new TypeError("reviews: title contains control bytes");
|
|
87
|
+
}
|
|
88
|
+
return s;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _body(s) {
|
|
92
|
+
if (s == null) return "";
|
|
93
|
+
if (typeof s !== "string") {
|
|
94
|
+
throw new TypeError("reviews: body must be a string");
|
|
95
|
+
}
|
|
96
|
+
if (s.length > MAX_BODY_LEN) {
|
|
97
|
+
throw new TypeError("reviews: body must be <= " + MAX_BODY_LEN + " characters");
|
|
98
|
+
}
|
|
99
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
100
|
+
throw new TypeError("reviews: body contains control bytes");
|
|
101
|
+
}
|
|
102
|
+
return s;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function _verifiedPurchase(n) {
|
|
106
|
+
if (n == null) return 0;
|
|
107
|
+
if (n === 0 || n === 1) return n;
|
|
108
|
+
if (n === true) return 1;
|
|
109
|
+
if (n === false) return 0;
|
|
110
|
+
throw new TypeError("reviews: verified_purchase must be 0 or 1");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function _statusFilter(s) {
|
|
114
|
+
if (s == null) return undefined;
|
|
115
|
+
if (typeof s !== "string" || ALLOWED_STATUSES.indexOf(s) === -1) {
|
|
116
|
+
throw new TypeError("reviews: status filter must be one of " + ALLOWED_STATUSES.join(", "));
|
|
117
|
+
}
|
|
118
|
+
return s;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function _limit(n) {
|
|
122
|
+
if (n == null) return DEFAULT_LIMIT;
|
|
123
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
124
|
+
throw new TypeError("reviews: limit must be an integer 1..." + MAX_LIST_LIMIT);
|
|
125
|
+
}
|
|
126
|
+
return n;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function _normalizeEmail(input) {
|
|
130
|
+
if (typeof input !== "string" || !input.length) {
|
|
131
|
+
throw new TypeError("reviews: customer_email must be a non-empty string");
|
|
132
|
+
}
|
|
133
|
+
var guardEmail = _b().guardEmail;
|
|
134
|
+
// Validate first so a non-OK report surfaces a precise reason
|
|
135
|
+
// instead of the sanitize-throw's catch-all message.
|
|
136
|
+
var report;
|
|
137
|
+
try {
|
|
138
|
+
report = guardEmail.validate(input, { profile: "strict" });
|
|
139
|
+
} catch (e) {
|
|
140
|
+
throw new TypeError("reviews: customer_email — " + (e && e.message || "invalid email"));
|
|
141
|
+
}
|
|
142
|
+
if (!report || report.ok === false) {
|
|
143
|
+
var first = (report && report.issues && report.issues[0]) || {};
|
|
144
|
+
throw new TypeError("reviews: customer_email — " + (first.snippet || first.ruleId || "refused at strict profile"));
|
|
145
|
+
}
|
|
146
|
+
var canonical;
|
|
147
|
+
try {
|
|
148
|
+
canonical = guardEmail.sanitize(input, { profile: "strict" });
|
|
149
|
+
} catch (e) {
|
|
150
|
+
throw new TypeError("reviews: customer_email — " + (e && e.message || "refused"));
|
|
151
|
+
}
|
|
152
|
+
// Lowercase the whole address before hashing — review identity is
|
|
153
|
+
// derived from a single canonical form. RFC 5321 local-part case
|
|
154
|
+
// sensitivity isn't a posture worth preserving here (the hash never
|
|
155
|
+
// lands in an SMTP routing path).
|
|
156
|
+
return canonical.toLowerCase().trim();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function _now() { return Date.now(); }
|
|
160
|
+
|
|
161
|
+
// ---- factory ------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
function create(opts) {
|
|
164
|
+
opts = opts || {};
|
|
165
|
+
var query = opts.query;
|
|
166
|
+
if (!query) {
|
|
167
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
168
|
+
}
|
|
169
|
+
// Pagination cursors are HMAC-tagged via b.pagination so an operator
|
|
170
|
+
// can't hand-craft one to skip past a hidden review or replay across
|
|
171
|
+
// deployments. The secret defaults to a dev-only placeholder so the
|
|
172
|
+
// primitive boots in tests; production deployments must supply a
|
|
173
|
+
// derived value (typically b.crypto.namespaceHash("review-cursor",
|
|
174
|
+
// D1_BRIDGE_SECRET)).
|
|
175
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
176
|
+
if (process.env.NODE_ENV === "production") {
|
|
177
|
+
throw new Error("reviews.create: opts.cursorSecret is required in production");
|
|
178
|
+
}
|
|
179
|
+
opts.cursorSecret = "review-cursor-secret-dev-only";
|
|
180
|
+
}
|
|
181
|
+
var cursorSecret = opts.cursorSecret;
|
|
182
|
+
|
|
183
|
+
function _hashCustomer(s) {
|
|
184
|
+
return _b().crypto.namespaceHash(CUSTOMER_NAMESPACE, s);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function _decodeCursor(cursor, orderKey, label) {
|
|
188
|
+
if (cursor == null) return null;
|
|
189
|
+
if (typeof cursor !== "string") {
|
|
190
|
+
throw new TypeError("reviews." + label + ": cursor must be an opaque string or null");
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
var state = _b().pagination.decodeCursor(cursor, cursorSecret);
|
|
194
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(orderKey)) {
|
|
195
|
+
throw new TypeError("reviews." + label + ": cursor orderKey mismatch");
|
|
196
|
+
}
|
|
197
|
+
return state.vals;
|
|
198
|
+
} catch (e) {
|
|
199
|
+
if (e instanceof TypeError) throw e;
|
|
200
|
+
throw new TypeError("reviews." + label + ": cursor — " + (e && e.message || "malformed"));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function _encodeNext(rows, orderKey, limit) {
|
|
205
|
+
var last = rows[rows.length - 1];
|
|
206
|
+
if (!last || rows.length < limit) return null;
|
|
207
|
+
return _b().pagination.encodeCursor({
|
|
208
|
+
orderKey: orderKey,
|
|
209
|
+
vals: [last.created_at, last.id],
|
|
210
|
+
forward: true,
|
|
211
|
+
}, cursorSecret);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
CUSTOMER_NAMESPACE: CUSTOMER_NAMESPACE,
|
|
216
|
+
MAX_TITLE_LEN: MAX_TITLE_LEN,
|
|
217
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
218
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
219
|
+
|
|
220
|
+
// Hash an identifier (customer id or normalised email) without
|
|
221
|
+
// writing. Operator-facing convenience for the storefront's
|
|
222
|
+
// "have I already reviewed this?" lookup.
|
|
223
|
+
hashCustomerId: function (customerId) {
|
|
224
|
+
_uuid(customerId, "customer_id");
|
|
225
|
+
return _hashCustomer(customerId);
|
|
226
|
+
},
|
|
227
|
+
hashCustomerEmail: function (email) {
|
|
228
|
+
return _hashCustomer(_normalizeEmail(email));
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
submit: async function (input) {
|
|
232
|
+
if (!input || typeof input !== "object") {
|
|
233
|
+
throw new TypeError("reviews.submit: input object required");
|
|
234
|
+
}
|
|
235
|
+
var productId = _uuid(input.product_id, "product_id");
|
|
236
|
+
var rating = _rating(input.rating);
|
|
237
|
+
var title = _title(input.title);
|
|
238
|
+
var body = _body(input.body);
|
|
239
|
+
var verified = _verifiedPurchase(input.verified_purchase);
|
|
240
|
+
|
|
241
|
+
var hasId = input.customer_id != null && input.customer_id !== "";
|
|
242
|
+
var hasEmail = input.customer_email != null && input.customer_email !== "";
|
|
243
|
+
if (!hasId && !hasEmail) {
|
|
244
|
+
throw new TypeError("reviews.submit: either customer_id or customer_email is required");
|
|
245
|
+
}
|
|
246
|
+
var customerId = null;
|
|
247
|
+
var customerHash;
|
|
248
|
+
if (hasId) {
|
|
249
|
+
customerId = _uuid(input.customer_id, "customer_id");
|
|
250
|
+
customerHash = _hashCustomer(customerId);
|
|
251
|
+
} else {
|
|
252
|
+
customerHash = _hashCustomer(_normalizeEmail(input.customer_email));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
var id = _b().uuid.v7();
|
|
256
|
+
var ts = _now();
|
|
257
|
+
await query(
|
|
258
|
+
"INSERT INTO reviews " +
|
|
259
|
+
"(id, product_id, customer_id, customer_id_hash, rating, title, body, verified_purchase, status, created_at, updated_at) " +
|
|
260
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?9)",
|
|
261
|
+
[id, productId, customerId, customerHash, rating, title, body, verified, ts],
|
|
262
|
+
);
|
|
263
|
+
return { id: id, status: "pending" };
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
get: async function (id) {
|
|
267
|
+
_uuid(id, "review id");
|
|
268
|
+
var r = await query("SELECT * FROM reviews WHERE id = ?1", [id]);
|
|
269
|
+
return r.rows[0] || null;
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
publish: async function (id) {
|
|
273
|
+
_uuid(id, "review id");
|
|
274
|
+
var ts = _now();
|
|
275
|
+
var existing = (await query("SELECT id, status FROM reviews WHERE id = ?1", [id])).rows[0];
|
|
276
|
+
if (!existing) {
|
|
277
|
+
var err = new Error("reviews.publish: review " + id + " not found");
|
|
278
|
+
err.code = "REVIEW_NOT_FOUND";
|
|
279
|
+
throw err;
|
|
280
|
+
}
|
|
281
|
+
await query(
|
|
282
|
+
"UPDATE reviews SET status = 'published', updated_at = ?1 WHERE id = ?2",
|
|
283
|
+
[ts, id],
|
|
284
|
+
);
|
|
285
|
+
var r = await query("SELECT * FROM reviews WHERE id = ?1", [id]);
|
|
286
|
+
return r.rows[0];
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
reject: async function (id, reason) {
|
|
290
|
+
_uuid(id, "review id");
|
|
291
|
+
if (reason != null) {
|
|
292
|
+
if (typeof reason !== "string") {
|
|
293
|
+
throw new TypeError("reviews.reject: reason must be a string");
|
|
294
|
+
}
|
|
295
|
+
if (reason.length > MAX_REJECT_REASON_LEN) {
|
|
296
|
+
throw new TypeError("reviews.reject: reason must be <= " + MAX_REJECT_REASON_LEN + " characters");
|
|
297
|
+
}
|
|
298
|
+
if (CONTROL_BYTE_RE.test(reason)) {
|
|
299
|
+
throw new TypeError("reviews.reject: reason contains control bytes");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
var ts = _now();
|
|
303
|
+
var existing = (await query("SELECT id, status FROM reviews WHERE id = ?1", [id])).rows[0];
|
|
304
|
+
if (!existing) {
|
|
305
|
+
var err = new Error("reviews.reject: review " + id + " not found");
|
|
306
|
+
err.code = "REVIEW_NOT_FOUND";
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
await query(
|
|
310
|
+
"UPDATE reviews SET status = 'rejected', updated_at = ?1 WHERE id = ?2",
|
|
311
|
+
[ts, id],
|
|
312
|
+
);
|
|
313
|
+
var r = await query("SELECT * FROM reviews WHERE id = ?1", [id]);
|
|
314
|
+
return r.rows[0];
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
// Storefront-facing listing. Defaults to published-only so a
|
|
318
|
+
// caller that forgets to pass status doesn't accidentally leak
|
|
319
|
+
// pending / rejected rows. Tuple cursor on (created_at DESC, id
|
|
320
|
+
// DESC) — newest reviews first.
|
|
321
|
+
listForProduct: async function (productId, listOpts) {
|
|
322
|
+
productId = _uuid(productId, "product_id");
|
|
323
|
+
listOpts = listOpts || {};
|
|
324
|
+
var status = listOpts.status === undefined ? "published" : _statusFilter(listOpts.status);
|
|
325
|
+
var limit = _limit(listOpts.limit);
|
|
326
|
+
var cursorVals = _decodeCursor(listOpts.cursor, REVIEW_ORDER_KEY, "listForProduct");
|
|
327
|
+
|
|
328
|
+
var sql, params;
|
|
329
|
+
if (status !== undefined && cursorVals) {
|
|
330
|
+
sql = "SELECT * FROM reviews WHERE product_id = ?1 AND status = ?2 " +
|
|
331
|
+
"AND (created_at < ?3 OR (created_at = ?3 AND id < ?4)) " +
|
|
332
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?5";
|
|
333
|
+
params = [productId, status, cursorVals[0], cursorVals[1], limit];
|
|
334
|
+
} else if (status !== undefined) {
|
|
335
|
+
sql = "SELECT * FROM reviews WHERE product_id = ?1 AND status = ?2 " +
|
|
336
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?3";
|
|
337
|
+
params = [productId, status, limit];
|
|
338
|
+
} else if (cursorVals) {
|
|
339
|
+
sql = "SELECT * FROM reviews WHERE product_id = ?1 " +
|
|
340
|
+
"AND (created_at < ?2 OR (created_at = ?2 AND id < ?3)) " +
|
|
341
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?4";
|
|
342
|
+
params = [productId, cursorVals[0], cursorVals[1], limit];
|
|
343
|
+
} else {
|
|
344
|
+
sql = "SELECT * FROM reviews WHERE product_id = ?1 " +
|
|
345
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?2";
|
|
346
|
+
params = [productId, limit];
|
|
347
|
+
}
|
|
348
|
+
var r = await query(sql, params);
|
|
349
|
+
return { rows: r.rows, next_cursor: _encodeNext(r.rows, REVIEW_ORDER_KEY, limit) };
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
// Aggregate ratings — published-only by construction; counts
|
|
353
|
+
// every star bucket so the storefront can draw the distribution
|
|
354
|
+
// bar chart without a second query.
|
|
355
|
+
summaryForProduct: async function (productId) {
|
|
356
|
+
productId = _uuid(productId, "product_id");
|
|
357
|
+
var r = await query(
|
|
358
|
+
"SELECT rating, COUNT(*) AS n FROM reviews " +
|
|
359
|
+
"WHERE product_id = ?1 AND status = 'published' GROUP BY rating",
|
|
360
|
+
[productId],
|
|
361
|
+
);
|
|
362
|
+
var distribution = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
|
363
|
+
var count = 0;
|
|
364
|
+
var sum = 0;
|
|
365
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
366
|
+
var row = r.rows[i];
|
|
367
|
+
var rating = Number(row.rating);
|
|
368
|
+
var n = Number(row.n);
|
|
369
|
+
distribution[rating] = n;
|
|
370
|
+
count += n;
|
|
371
|
+
sum += rating * n;
|
|
372
|
+
}
|
|
373
|
+
var avg = count === 0 ? 0 : sum / count;
|
|
374
|
+
return { count: count, avg_rating: avg, distribution: distribution };
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
// Customer's review history, keyed by the hash (never the raw
|
|
378
|
+
// id or email — the caller hashes whatever it has via
|
|
379
|
+
// `hashCustomerId` / `hashCustomerEmail` before calling). Same
|
|
380
|
+
// (created_at DESC, id DESC) cursor shape as listForProduct.
|
|
381
|
+
byCustomer: async function (customerHash, listOpts) {
|
|
382
|
+
if (typeof customerHash !== "string" || !customerHash.length) {
|
|
383
|
+
throw new TypeError("reviews.byCustomer: customer_id_hash must be a non-empty string");
|
|
384
|
+
}
|
|
385
|
+
listOpts = listOpts || {};
|
|
386
|
+
var limit = _limit(listOpts.limit);
|
|
387
|
+
var cursorVals = _decodeCursor(listOpts.cursor, CUSTOMER_ORDER_KEY, "byCustomer");
|
|
388
|
+
|
|
389
|
+
var sql, params;
|
|
390
|
+
if (cursorVals) {
|
|
391
|
+
sql = "SELECT * FROM reviews WHERE customer_id_hash = ?1 " +
|
|
392
|
+
"AND (created_at < ?2 OR (created_at = ?2 AND id < ?3)) " +
|
|
393
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?4";
|
|
394
|
+
params = [customerHash, cursorVals[0], cursorVals[1], limit];
|
|
395
|
+
} else {
|
|
396
|
+
sql = "SELECT * FROM reviews WHERE customer_id_hash = ?1 " +
|
|
397
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?2";
|
|
398
|
+
params = [customerHash, limit];
|
|
399
|
+
}
|
|
400
|
+
var r = await query(sql, params);
|
|
401
|
+
return { rows: r.rows, next_cursor: _encodeNext(r.rows, CUSTOMER_ORDER_KEY, limit) };
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
module.exports = {
|
|
407
|
+
create: create,
|
|
408
|
+
CUSTOMER_NAMESPACE: CUSTOMER_NAMESPACE,
|
|
409
|
+
MAX_TITLE_LEN: MAX_TITLE_LEN,
|
|
410
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
411
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
412
|
+
};
|
package/lib/storefront.js
CHANGED
|
@@ -1085,6 +1085,12 @@ function renderOrder(opts) {
|
|
|
1085
1085
|
|
|
1086
1086
|
var CART_PAGE =
|
|
1087
1087
|
"<section class=\"cart-page\">\n" +
|
|
1088
|
+
" <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
|
|
1089
|
+
" <ol>\n" +
|
|
1090
|
+
" <li><a href=\"/\">Shop</a></li>\n" +
|
|
1091
|
+
" <li aria-current=\"page\">Cart</li>\n" +
|
|
1092
|
+
" </ol>\n" +
|
|
1093
|
+
" </nav>\n" +
|
|
1088
1094
|
" <header class=\"section-head\">\n" +
|
|
1089
1095
|
" <p class=\"eyebrow\">Your cart</p>\n" +
|
|
1090
1096
|
" <h1 class=\"section-head__title\">Review your items</h1>\n" +
|
|
@@ -1110,6 +1116,28 @@ var CART_PAGE =
|
|
|
1110
1116
|
" </div>\n" +
|
|
1111
1117
|
"</section>\n";
|
|
1112
1118
|
|
|
1119
|
+
var CART_EMPTY_PAGE =
|
|
1120
|
+
"<section class=\"cart-page cart-page--empty\">\n" +
|
|
1121
|
+
" <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n" +
|
|
1122
|
+
" <ol>\n" +
|
|
1123
|
+
" <li><a href=\"/\">Shop</a></li>\n" +
|
|
1124
|
+
" <li aria-current=\"page\">Cart</li>\n" +
|
|
1125
|
+
" </ol>\n" +
|
|
1126
|
+
" </nav>\n" +
|
|
1127
|
+
" <div class=\"cart-empty\">\n" +
|
|
1128
|
+
" <div class=\"cart-empty__card\">\n" +
|
|
1129
|
+
" <p class=\"cart-empty__icon\" aria-hidden=\"true\">🛒</p>\n" +
|
|
1130
|
+
" <p class=\"eyebrow cart-empty__eyebrow\">Cart</p>\n" +
|
|
1131
|
+
" <h1 class=\"cart-empty__title\">Your cart is empty</h1>\n" +
|
|
1132
|
+
" <p class=\"cart-empty__lede\">Browse the catalog and the products you add show up here. Items hold their price at add-time, not at checkout.</p>\n" +
|
|
1133
|
+
" <div class=\"cart-empty__cta\">\n" +
|
|
1134
|
+
" <a href=\"/\" class=\"btn-primary\">Browse products <span aria-hidden=\"true\">→</span></a>\n" +
|
|
1135
|
+
" <a href=\"#site-search-q\" class=\"btn-ghost\">Find a specific product</a>\n" +
|
|
1136
|
+
" </div>\n" +
|
|
1137
|
+
" </div>\n" +
|
|
1138
|
+
" </div>\n" +
|
|
1139
|
+
"</section>\n";
|
|
1140
|
+
|
|
1113
1141
|
function renderCart(opts) {
|
|
1114
1142
|
if (!opts) throw new TypeError("storefront.renderCart: opts required");
|
|
1115
1143
|
var lines = opts.lines || [];
|
|
@@ -1156,26 +1184,30 @@ function renderCart(opts) {
|
|
|
1156
1184
|
return String(s == null ? "" : s)
|
|
1157
1185
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1158
1186
|
}
|
|
1159
|
-
var
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1187
|
+
var body;
|
|
1188
|
+
if (rendered.length === 0) {
|
|
1189
|
+
body = CART_EMPTY_PAGE;
|
|
1190
|
+
} else {
|
|
1191
|
+
var rows = rendered.map(function (l) {
|
|
1192
|
+
var thumb = l.image_url
|
|
1193
|
+
? "<span class=\"cart-line__thumb\"><img src=\"" + _escAttr(l.image_url) + "\" alt=\"" + _escAttr(l.image_alt) + "\" loading=\"lazy\"></span>"
|
|
1194
|
+
: "<span class=\"cart-line__thumb cart-line__thumb--empty\" aria-hidden=\"true\"></span>";
|
|
1195
|
+
return _render(CART_LINE_EDITABLE, {
|
|
1196
|
+
sku: l.sku,
|
|
1197
|
+
qty: l.qty,
|
|
1198
|
+
unit: l.unit,
|
|
1199
|
+
total: l.total,
|
|
1200
|
+
line_id: l.id,
|
|
1201
|
+
product_title: l.product_title,
|
|
1202
|
+
product_url: l.product_url,
|
|
1203
|
+
}).replace("RAW_CART_LINE_THUMB", thumb);
|
|
1204
|
+
}).join("");
|
|
1205
|
+
body = _render(CART_PAGE, {
|
|
1206
|
+
line_rows: "RAW_LINES",
|
|
1207
|
+
subtotal: subtotal,
|
|
1208
|
+
total: total,
|
|
1209
|
+
}).replace("RAW_LINES", rows);
|
|
1210
|
+
}
|
|
1179
1211
|
return _wrap({
|
|
1180
1212
|
title: "Cart",
|
|
1181
1213
|
shop_name: shopName,
|