create-shopify-firebase-app 2.0.6 → 2.1.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/README.md +122 -74
- package/lib/index.js +540 -238
- package/lib/preflight.js +358 -0
- package/lib/provision.js +187 -5
- package/package.json +1 -1
- package/templates/js/functions/package.json +6 -6
- package/templates/js/functions/src/admin-api.js +1 -1
- package/templates/js/functions/src/auth.js +45 -13
- package/templates/js/functions/src/firebase.js +6 -4
- package/templates/js/functions/src/index.js +14 -0
- package/templates/shared/extensions/theme-block/blocks/app-block.liquid +1 -1
- package/templates/shared/extensions/theme-block/shopify.extension.toml +1 -1
- package/templates/shared/firebase.json +1 -1
- package/templates/shared/firestore.rules +10 -2
- package/templates/shopify.app.toml +1 -1
- package/templates/ts/functions/package.json +10 -10
- package/templates/ts/functions/src/admin-api.ts +5 -2
- package/templates/ts/functions/src/auth.ts +69 -15
- package/templates/ts/functions/src/firebase.ts +8 -4
- package/templates/ts/functions/tsconfig.json +2 -1
- package/templates/web/css/app.css +78 -1138
- package/templates/web/index.html +79 -68
- package/templates/web/js/app.js +60 -33
- package/templates/web/js/pages/home.js +33 -22
- package/templates/web/js/pages/polaris-demo.js +25 -83
- package/templates/web/js/pages/products.js +100 -114
- package/templates/web/js/pages/settings.js +68 -166
- package/templates/web/polaris.html +1221 -950
- package/templates/web/products.html +35 -40
- package/templates/web/settings.html +69 -17
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Products page logic
|
|
3
3
|
* Search, display product cards, show detail modal, resource picker demo.
|
|
4
|
+
* Uses Polaris Web Components for all UI rendering.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
(function () {
|
|
@@ -9,17 +10,27 @@
|
|
|
9
10
|
var searchInput = null;
|
|
10
11
|
var container = null;
|
|
11
12
|
var resultsCount = null;
|
|
13
|
+
var resultsSection = null;
|
|
12
14
|
|
|
13
15
|
// ── Init ────────────────────────────────────────────────────────
|
|
16
|
+
// Show the count card only when there is a count. An empty <s-section>
|
|
17
|
+
// still renders as a padded card, which reads as a stray blank box.
|
|
18
|
+
function setResultsCount(text) {
|
|
19
|
+
if (resultsCount) resultsCount.textContent = text || "";
|
|
20
|
+
if (resultsSection) resultsSection.style.display = text ? "" : "none";
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
document.addEventListener("DOMContentLoaded", function () {
|
|
15
24
|
searchInput = document.getElementById("search-input");
|
|
16
25
|
container = document.getElementById("products-container");
|
|
17
26
|
resultsCount = document.getElementById("results-count");
|
|
27
|
+
resultsSection = document.getElementById("results-section");
|
|
18
28
|
|
|
19
29
|
if (searchInput) {
|
|
20
|
-
|
|
30
|
+
var debouncedSearch = debounce(handleSearch, 400);
|
|
31
|
+
searchInput.addEventListener("input", debouncedSearch);
|
|
32
|
+
searchInput.addEventListener("change", debouncedSearch);
|
|
21
33
|
|
|
22
|
-
// Search on Enter key
|
|
23
34
|
searchInput.addEventListener("keydown", function (e) {
|
|
24
35
|
if (e.key === "Enter") {
|
|
25
36
|
e.preventDefault();
|
|
@@ -35,49 +46,51 @@
|
|
|
35
46
|
|
|
36
47
|
if (!query) {
|
|
37
48
|
container.innerHTML =
|
|
38
|
-
'<
|
|
39
|
-
'<
|
|
40
|
-
'<
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (resultsCount) resultsCount.textContent = "";
|
|
49
|
+
'<s-box padding="large-1200" border="base" borderRadius="base">' +
|
|
50
|
+
'<s-stack alignItems="center" gap="base">' +
|
|
51
|
+
'<s-text variant="headingMd">Search for products</s-text>' +
|
|
52
|
+
'<s-text color="subdued">Enter a search term above to find products in your store, or use the Resource Picker to browse.</s-text>' +
|
|
53
|
+
'<s-button variant="primary" onclick="document.getElementById(\'search-input\').focus()">Start searching</s-button>' +
|
|
54
|
+
"</s-stack></s-box>";
|
|
55
|
+
setResultsCount("");
|
|
46
56
|
return;
|
|
47
57
|
}
|
|
48
58
|
|
|
49
|
-
// Show loading
|
|
50
|
-
container.innerHTML =
|
|
59
|
+
// Show loading
|
|
60
|
+
container.innerHTML =
|
|
61
|
+
'<s-box padding="large-400">' +
|
|
62
|
+
'<s-stack alignItems="center" gap="base">' +
|
|
63
|
+
"<s-spinner></s-spinner>" +
|
|
64
|
+
'<s-text color="subdued">Searching...</s-text>' +
|
|
65
|
+
"</s-stack></s-box>";
|
|
51
66
|
|
|
52
67
|
try {
|
|
53
68
|
var data = await apiFetch("/api/products/search?q=" + encodeURIComponent(query));
|
|
54
69
|
var products = data.products || [];
|
|
55
70
|
|
|
56
|
-
|
|
57
|
-
|
|
71
|
+
{
|
|
72
|
+
setResultsCount(products.length + " product" + (products.length !== 1 ? "s" : "") + " found");
|
|
58
73
|
}
|
|
59
74
|
|
|
60
75
|
if (products.length === 0) {
|
|
61
76
|
container.innerHTML =
|
|
62
|
-
'<
|
|
63
|
-
'<
|
|
64
|
-
'<
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
"</div></div>";
|
|
77
|
+
'<s-box padding="large-1200" border="base" borderRadius="base">' +
|
|
78
|
+
'<s-stack alignItems="center" gap="base">' +
|
|
79
|
+
'<s-text variant="headingMd">No products found</s-text>' +
|
|
80
|
+
'<s-text color="subdued">No products match "' + escapeHtml(query) + '". Try a different search term.</s-text>' +
|
|
81
|
+
"</s-stack></s-box>";
|
|
68
82
|
return;
|
|
69
83
|
}
|
|
70
84
|
|
|
71
85
|
renderProductGrid(products);
|
|
72
86
|
} catch (err) {
|
|
73
87
|
container.innerHTML =
|
|
74
|
-
'<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
"
|
|
79
|
-
|
|
80
|
-
if (resultsCount) resultsCount.textContent = "";
|
|
88
|
+
'<s-banner tone="critical">' +
|
|
89
|
+
"<s-text>" +
|
|
90
|
+
"<s-text fontWeight=\"semibold\">Search failed</s-text> " +
|
|
91
|
+
escapeHtml(err.message) +
|
|
92
|
+
"</s-text></s-banner>";
|
|
93
|
+
setResultsCount("");
|
|
81
94
|
}
|
|
82
95
|
}
|
|
83
96
|
|
|
@@ -98,48 +111,31 @@
|
|
|
98
111
|
'<div class="product-card-image">' +
|
|
99
112
|
(image
|
|
100
113
|
? '<img src="' + escapeAttr(image) + '" alt="' + escapeAttr(p.title || "") + '" loading="lazy">'
|
|
101
|
-
: '<
|
|
114
|
+
: '<s-text color="subdued">No image</s-text>') +
|
|
102
115
|
"</div>" +
|
|
103
116
|
'<div class="product-card-body">' +
|
|
104
|
-
'<
|
|
105
|
-
(vendor ? '<
|
|
106
|
-
'<
|
|
107
|
-
'<
|
|
108
|
-
'<
|
|
109
|
-
"</
|
|
117
|
+
'<s-text fontWeight="semibold">' + escapeHtml(p.title || "Untitled") + "</s-text>" +
|
|
118
|
+
(vendor ? '<s-text color="subdued" variant="bodySm">' + escapeHtml(vendor) + "</s-text>" : "") +
|
|
119
|
+
'<s-stack direction="inline" gap="small-200" alignItems="center" style="margin-top: 8px;">' +
|
|
120
|
+
'<s-text fontWeight="semibold">' + escapeHtml(price) + "</s-text>" +
|
|
121
|
+
'<s-badge tone="' + status.tone + '">' + escapeHtml(status.label) + "</s-badge>" +
|
|
122
|
+
"</s-stack></div></div>";
|
|
110
123
|
}
|
|
111
124
|
|
|
112
125
|
html += "</div>";
|
|
113
126
|
container.innerHTML = html;
|
|
114
127
|
}
|
|
115
128
|
|
|
116
|
-
// ── Skeleton loading ────────────────────────────────────────────
|
|
117
|
-
function renderSkeletonGrid() {
|
|
118
|
-
var html = '<div class="product-grid">';
|
|
119
|
-
for (var i = 0; i < 6; i++) {
|
|
120
|
-
html +=
|
|
121
|
-
'<div class="product-card">' +
|
|
122
|
-
'<div class="skeleton skeleton-image"></div>' +
|
|
123
|
-
'<div class="product-card-body">' +
|
|
124
|
-
'<div class="skeleton skeleton-heading" style="width:80%;"></div>' +
|
|
125
|
-
'<div class="skeleton skeleton-text" style="width:50%;"></div>' +
|
|
126
|
-
'<div class="skeleton skeleton-text" style="width:60%;"></div>' +
|
|
127
|
-
"</div></div>";
|
|
128
|
-
}
|
|
129
|
-
html += "</div>";
|
|
130
|
-
return html;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
129
|
// ── Show product detail in modal ────────────────────────────────
|
|
134
130
|
window.showProductDetail = async function (productId) {
|
|
135
131
|
var modal = document.getElementById("product-detail-modal");
|
|
136
132
|
var content = document.getElementById("product-detail-content");
|
|
137
133
|
|
|
138
134
|
content.innerHTML =
|
|
139
|
-
'<
|
|
140
|
-
|
|
141
|
-
"
|
|
142
|
-
"</
|
|
135
|
+
'<s-stack alignItems="center" gap="base">' +
|
|
136
|
+
"<s-spinner></s-spinner>" +
|
|
137
|
+
'<s-text color="subdued">Loading product details...</s-text>' +
|
|
138
|
+
"</s-stack>";
|
|
143
139
|
|
|
144
140
|
modal.show();
|
|
145
141
|
|
|
@@ -149,19 +145,17 @@
|
|
|
149
145
|
renderProductDetail(content, p);
|
|
150
146
|
} catch (err) {
|
|
151
147
|
content.innerHTML =
|
|
152
|
-
'<
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
"
|
|
157
|
-
"</div></div>";
|
|
148
|
+
'<s-banner tone="critical">' +
|
|
149
|
+
"<s-text>" +
|
|
150
|
+
"<s-text fontWeight=\"semibold\">Could not load product</s-text> " +
|
|
151
|
+
escapeHtml(err.message) +
|
|
152
|
+
"</s-text></s-banner>";
|
|
158
153
|
}
|
|
159
154
|
};
|
|
160
155
|
|
|
161
156
|
function renderProductDetail(container, p) {
|
|
162
157
|
var image = getProductImage(p);
|
|
163
158
|
var status = getProductStatus(p);
|
|
164
|
-
// GraphQL returns variants as { edges: [{ node: {...} }] }
|
|
165
159
|
var variants = [];
|
|
166
160
|
if (p.variants && p.variants.edges) {
|
|
167
161
|
for (var vi = 0; vi < p.variants.edges.length; vi++) {
|
|
@@ -171,44 +165,40 @@
|
|
|
171
165
|
variants = p.variants;
|
|
172
166
|
}
|
|
173
167
|
|
|
174
|
-
var html =
|
|
175
|
-
|
|
168
|
+
var html = '<s-stack gap="base">';
|
|
169
|
+
|
|
170
|
+
// Image + info grid
|
|
171
|
+
html += '<s-grid gridTemplateColumns="@container (inline-size <= 400px) 1fr, 200px 1fr" gap="base" alignItems="start">';
|
|
176
172
|
|
|
177
173
|
// Image
|
|
178
|
-
html += '<div>';
|
|
179
174
|
if (image) {
|
|
180
|
-
html += '<img src="' + escapeAttr(image) + '" alt="' + escapeAttr(p.title || "") + '" style="width:100%; border-radius: 8px; border: 1px solid
|
|
175
|
+
html += '<img src="' + escapeAttr(image) + '" alt="' + escapeAttr(p.title || "") + '" style="width:100%; border-radius: 8px; border: 1px solid #e1e3e5;">';
|
|
181
176
|
} else {
|
|
182
|
-
html += '<
|
|
177
|
+
html += '<s-box padding="large-400" border="base" borderRadius="base" background="bg-surface-secondary"><s-stack alignItems="center"><s-text color="subdued">No image</s-text></s-stack></s-box>';
|
|
183
178
|
}
|
|
184
|
-
html += "</div>";
|
|
185
179
|
|
|
186
180
|
// Info
|
|
187
|
-
html += "
|
|
188
|
-
html += '<
|
|
189
|
-
|
|
190
|
-
html += '<
|
|
191
|
-
html += infoRow("
|
|
192
|
-
if (p.
|
|
193
|
-
|
|
194
|
-
if (p.
|
|
195
|
-
html +=
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
// Description — GraphQL uses "description" not "body_html"
|
|
181
|
+
html += '<s-stack gap="small-200">';
|
|
182
|
+
html += '<s-text variant="headingLg">' + escapeHtml(p.title || "Untitled") + "</s-text>";
|
|
183
|
+
html += '<s-stack gap="small-200">';
|
|
184
|
+
html += infoRow("Status", '<s-badge tone="' + status.tone + '">' + escapeHtml(status.label) + "</s-badge>");
|
|
185
|
+
if (p.vendor) html += infoRow("Vendor", "<s-text>" + escapeHtml(p.vendor) + "</s-text>");
|
|
186
|
+
if (p.productType) html += infoRow("Type", "<s-text>" + escapeHtml(p.productType) + "</s-text>");
|
|
187
|
+
html += infoRow("Price", "<s-text>" + escapeHtml(getProductPrice(p)) + "</s-text>");
|
|
188
|
+
if (p.totalInventory !== undefined) html += infoRow("Total inventory", "<s-text>" + escapeHtml(String(p.totalInventory)) + "</s-text>");
|
|
189
|
+
html += "</s-stack></s-stack>";
|
|
190
|
+
html += "</s-grid>";
|
|
191
|
+
|
|
192
|
+
// Description
|
|
201
193
|
var desc = p.description || p.body_html || "";
|
|
202
194
|
if (desc) {
|
|
203
|
-
html +=
|
|
204
|
-
html += '<
|
|
205
|
-
html += '<div class="text-secondary">' + escapeHtml(desc) + "</div>";
|
|
195
|
+
html += '<s-text variant="headingSm">Description</s-text>';
|
|
196
|
+
html += '<s-text color="subdued">' + escapeHtml(desc) + "</s-text>";
|
|
206
197
|
}
|
|
207
198
|
|
|
208
199
|
// Variants table
|
|
209
200
|
if (variants.length > 0) {
|
|
210
|
-
html +=
|
|
211
|
-
html += '<h3 class="mb-3">Variants (' + variants.length + ")</h3>";
|
|
201
|
+
html += '<s-text variant="headingSm">Variants (' + variants.length + ")</s-text>";
|
|
212
202
|
html += '<div class="table-container"><table>';
|
|
213
203
|
html += "<thead><tr><th>Title</th><th>Price</th><th>SKU</th><th>Inventory</th></tr></thead><tbody>";
|
|
214
204
|
for (var i = 0; i < variants.length; i++) {
|
|
@@ -217,21 +207,21 @@
|
|
|
217
207
|
html += "<td>" + escapeHtml(v.title || "--") + "</td>";
|
|
218
208
|
html += "<td>" + escapeHtml(v.price ? formatCurrency(v.price) : "--") + "</td>";
|
|
219
209
|
html += "<td><code>" + escapeHtml(v.sku || "--") + "</code></td>";
|
|
220
|
-
|
|
221
|
-
html += "<td>" + (v.inventoryQuantity !== undefined ? v.inventoryQuantity : (v.inventory_quantity !== undefined ? v.inventory_quantity : "--")) + "</td>";
|
|
210
|
+
html += "<td>" + escapeHtml(String(v.inventoryQuantity !== undefined ? v.inventoryQuantity : (v.inventory_quantity !== undefined ? v.inventory_quantity : "--"))) + "</td>";
|
|
222
211
|
html += "</tr>";
|
|
223
212
|
}
|
|
224
213
|
html += "</tbody></table></div>";
|
|
225
214
|
}
|
|
226
215
|
|
|
216
|
+
html += "</s-stack>";
|
|
227
217
|
container.innerHTML = html;
|
|
228
218
|
}
|
|
229
219
|
|
|
230
220
|
function infoRow(label, valueHtml) {
|
|
231
|
-
return
|
|
232
|
-
'<
|
|
233
|
-
|
|
234
|
-
"</
|
|
221
|
+
return '<s-grid gridTemplateColumns="120px 1fr" gap="small-200" alignItems="center">' +
|
|
222
|
+
'<s-text color="subdued">' + escapeHtml(label) + "</s-text>" +
|
|
223
|
+
valueHtml +
|
|
224
|
+
"</s-grid>";
|
|
235
225
|
}
|
|
236
226
|
|
|
237
227
|
// ── Resource Picker ─────────────────────────────────────────────
|
|
@@ -252,17 +242,17 @@
|
|
|
252
242
|
var modal = document.getElementById("picker-result-modal");
|
|
253
243
|
var content = document.getElementById("picker-result-content");
|
|
254
244
|
|
|
255
|
-
var html = '<
|
|
256
|
-
html += '<
|
|
245
|
+
var html = '<s-stack gap="base">';
|
|
246
|
+
html += '<s-text color="subdued">You selected ' + selected.length + " product(s):</s-text>";
|
|
257
247
|
for (var i = 0; i < selected.length; i++) {
|
|
258
248
|
var item = selected[i];
|
|
259
|
-
html += "
|
|
260
|
-
html += '<
|
|
261
|
-
html += '<
|
|
262
|
-
html += "</
|
|
249
|
+
html += '<s-stack direction="inline" gap="base" alignItems="center">';
|
|
250
|
+
html += '<s-text fontWeight="semibold">' + escapeHtml(item.title || "Untitled") + "</s-text>";
|
|
251
|
+
html += '<s-badge tone="info">Selected</s-badge>';
|
|
252
|
+
html += "</s-stack>";
|
|
263
253
|
}
|
|
264
|
-
html += "
|
|
265
|
-
html +=
|
|
254
|
+
html += '<s-text color="subdued" variant="bodySm">Resource Picker returns product data you can use in your app logic.</s-text>';
|
|
255
|
+
html += "</s-stack>";
|
|
266
256
|
|
|
267
257
|
content.innerHTML = html;
|
|
268
258
|
modal.show();
|
|
@@ -282,6 +272,10 @@
|
|
|
282
272
|
}
|
|
283
273
|
|
|
284
274
|
function getProductPrice(product) {
|
|
275
|
+
if (product.variants && product.variants.edges && product.variants.edges.length > 0) {
|
|
276
|
+
var price = product.variants.edges[0].node.price;
|
|
277
|
+
if (price) return formatCurrency(price);
|
|
278
|
+
}
|
|
285
279
|
if (product.variants && product.variants.length > 0) {
|
|
286
280
|
var price = product.variants[0].price;
|
|
287
281
|
if (price) return formatCurrency(price);
|
|
@@ -295,25 +289,17 @@
|
|
|
295
289
|
|
|
296
290
|
function getProductStatus(product) {
|
|
297
291
|
var status = (product.status || "").toLowerCase();
|
|
298
|
-
if (status === "active") return { label: "Active",
|
|
299
|
-
if (status === "draft") return { label: "Draft",
|
|
300
|
-
if (status === "archived") return { label: "Archived",
|
|
301
|
-
return { label: status || "Active",
|
|
292
|
+
if (status === "active") return { label: "Active", tone: "success" };
|
|
293
|
+
if (status === "draft") return { label: "Draft", tone: "info" };
|
|
294
|
+
if (status === "archived") return { label: "Archived", tone: "warning" };
|
|
295
|
+
return { label: status || "Active", tone: "success" };
|
|
302
296
|
}
|
|
303
297
|
|
|
304
298
|
function extractId(gid) {
|
|
305
|
-
// Extract numeric ID from GID like "gid://shopify/Product/123"
|
|
306
299
|
if (!gid) return "";
|
|
307
300
|
var parts = String(gid).split("/");
|
|
308
301
|
return parts[parts.length - 1] || gid;
|
|
309
302
|
}
|
|
310
303
|
|
|
311
|
-
|
|
312
|
-
return String(str)
|
|
313
|
-
.replace(/&/g, "&")
|
|
314
|
-
.replace(/"/g, """)
|
|
315
|
-
.replace(/'/g, "'")
|
|
316
|
-
.replace(/</g, "<")
|
|
317
|
-
.replace(/>/g, ">");
|
|
318
|
-
}
|
|
304
|
+
// escapeAttr is provided globally by app.js
|
|
319
305
|
})();
|