@base44/app-plugin-commerce 0.1.12 → 0.1.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44/app-plugin-commerce",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Base44 Commerce plugin — entities, backend functions, shared commerce engine, admin UI and the commerce skill, shipped as copyable source",
5
5
  "keywords": [
6
6
  "base44",
@@ -14,7 +14,7 @@ Operational guidance for extending, operating and building on the Base44 Commerc
14
14
 
15
15
  ## IMPORTANT — first-time installation
16
16
 
17
- If the template was just installed (or you are installing it right now), read [`.agents/skills/commerce/post-installation.md`](./post-installation.md) **before anything else — and, unless the user has a special requirement, read nothing else**: it alone covers embedding the admin pages, the three-layer admin-role enforcement (do not weaken), seeding the store's data — **one `commerce/seed-store` call takes the whole catalog** (products with attributes; variants, categories, ribbons and Shipping & Tax Locations created internally — §2) — **card payments in one file** (§2.2: a complete Stripe implementation to paste over `shared/commerce/card-payment.ts`; the payment webhook is premade), and the **storefront quick start** (§3): logic-only chunks for product list → product page → cart → checkout. The references below and the API docs are for requests that go beyond that happy path, not for the install. The full install-from-scratch steps are in [`.agents/skills/commerce/installation-guidelines.md`](./installation-guidelines.md).
17
+ If the template was just installed (or you are installing it right now), read [`.agents/skills/commerce/post-installation.md`](./post-installation.md) **before anything else — and, unless the user has a special requirement, read nothing else**: it alone covers embedding the admin pages, the three-layer admin-role enforcement (do not weaken), seeding the store's data — **one `commerce/seed-store` call takes the whole catalog** (products with attributes; variants, categories, ribbons and Shipping & Tax Locations created internally — §2) — **card payments in one file, wired only if — and whenever in the flow — the store actually needs online payments** (§2.2 opens with that decision, then a complete Stripe implementation to paste over `shared/commerce/card-payment.ts`; the payment webhook is premade), and the **storefront quick start** (§3): logic-only chunks for product list → product page → cart → checkout. The references below and the API docs are for requests that go beyond that happy path, not for the install. The full install-from-scratch steps are in [`.agents/skills/commerce/installation-guidelines.md`](./installation-guidelines.md).
18
18
 
19
19
  ## Working on the UI
20
20
 
@@ -1,8 +1,8 @@
1
1
  # Post-installation
2
2
 
3
- What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, seed the store's data, wire payments, and build the storefront from the quick start below. Installed into the app at `.agents/skills/commerce/post-installation.md`.
3
+ What to do right after the static installation ([`installation-guidelines.md`](./installation-guidelines.md)): embed the admin pages into the app, seed the store's data, build the storefront from the quick start below, and — **if and when the store needs online payments, a call you make deliberately (§2.2)** — wire a payment provider. Installed into the app at `.agents/skills/commerce/post-installation.md`.
4
4
 
5
- > **This file is intentionally the whole job.** Unless the user has a special requirement (a non-Stripe payment provider, signature-verified webhooks, filters/reviews/accounts beyond the happy path), do **not** open the `references/` guides or the `docs/` API references — everything needed to ship a working store (admin mount, seeding, product list, product page, cart, checkout, order-received, card payments) is on this page, ending with the [Done when](#4-done-when) checklist. Reading more first just burns time.
5
+ > **This file is intentionally the whole job.** Unless the user has a special requirement (a non-Stripe payment provider, signature-verified webhooks, filters/reviews/accounts beyond the happy path), do **not** open the `references/` guides or the `docs/` API references — everything needed to ship a working store (admin mount, seeding, product list, product page, cart, checkout, order-received, and card payments for the stores that need them) is on this page, ending with the [Done when](#4-done-when) checklist. Reading more first just burns time.
6
6
 
7
7
  ---
8
8
 
@@ -145,6 +145,12 @@ A successful response means the data is in — the catalog and settings are live
145
145
 
146
146
  The order side of payments is **already implemented** (checkout routing, confirmation, payment links, refund records). The `offline` gateway — and any option the admin adds in Settings → Payments — works with nothing to configure: the order goes on-hold with the option's description as instructions.
147
147
 
148
+ **Whether the store needs online payments — and when in the install to wire them — is your decision, not a fixed step.** Nothing else on this page depends on this section: the admin, the seed and the entire storefront work with no provider wired (until then the card option answers `503 no_card_payment_provider` at checkout, and §3.4 shows the graceful fallback). Decide from what the user is actually building:
149
+
150
+ - **The store doesn't take online payments** (cash on delivery, bank transfer, invoicing, pickup, quote-based…) → seed `payment_methods` without `"card"` and skip the rest of this section — the manual gateways are complete as shipped.
151
+ - **The store does take online payments** → this section is the how-to (Stripe below is the reference), but *you* pick the point in the flow. The wiring is self-contained and needs the user's provider secret key, so it usually fits **after** the storefront is standing — asking for a Stripe key before there is anything to sell is premature. What's non-negotiable is only the end state: don't hand over a finished store with the card gateway enabled but no provider behind it (wire one, or disable the gateway).
152
+ - **Unclear from the request** → build everything else first and raise payments with the user at the end, or ask.
153
+
148
154
  Which methods the store offers is **seed data**: pass `payment_methods` to `commerce/seed-store` (§2) — e.g. `["card"]` for a card-only store — instead of ever editing `commerce.PaymentGateway` records yourself.
149
155
 
150
156
  The **Credit card** option needs a payment provider — **any** provider works (Stripe, PayPal, Adyen, a local PSP…), and whichever one it is, wiring it touches exactly **one file**: `base44/shared/commerce/card-payment.ts` — four functions, each backing a premade flow. `commerce/payment-webhook` (the function) is **premade — do not edit it**: it calls this file's `parseWebhook` to validate each event and **never trusts an event body on its own** — an unverified event only *names* an order, and whether money arrived is asked of the provider itself through `checkCardPaymentPaid`, so a forged webhook call can never mark an order paid and **no signing secret is needed**. Until the file is implemented the card option answers `503 no_card_payment_provider` at checkout (or switch it off in Settings → Payments).
@@ -287,6 +293,19 @@ export const store = createStorefront(base44);
287
293
 
288
294
  The client owns the two things hand-rolled storefronts keep getting wrong, so **don't reimplement either**: the **`cart_token` lifecycle** (sent with every cart/checkout call, re-persisted from every response — a stale token silently starts a fresh cart; rolling 48 h expiry; cleared when checkout consumes the cart) and the **store-info cache** (`payment_gateways`, currency, countries live **only** on `get-store-info` — the cart view never carries them). For anything beyond its methods, `store.inv(fn, payload)` unwraps the `{ success, data }` envelope (`res.data.data`).
289
295
 
296
+ **What each method resolves to** — the client already unwraps every envelope, so take these shapes at face value (no `.data`, and no `.categories` on a list result):
297
+
298
+ | Method | Resolves to |
299
+ |---|---|
300
+ | `store.getStoreInfo()` | `{ settings, payment_gateways, countries, currencies }` — cached; the **only** source of `payment_gateways` |
301
+ | `store.listProducts(params)` | `{ products, page, per_page, has_next }` — a page object; the array is `products` |
302
+ | `store.getProduct(slug \| { id })` | `{ product, variations, categories, ribbons, reviews }` |
303
+ | `store.listCategories()` | an **array** of root categories, subcategories nested under `children` |
304
+ | `store.listRibbons()` | an **array** of `{ id, name, count }` |
305
+ | `store.getCart()` and every cart mutation | the full cart view (`getCart()` alone may resolve to `null` — no cart yet); `cart.items[n].attributes` is an **array** of `{ name, option }`, never a map |
306
+ | `store.placeOrder(details)` | `{ order_id, order_number, order_key, status, totals, order, payment_instructions, payment }` |
307
+ | `store.completeReturn()` | `{ state, order, payment_link, payment_instructions }` |
308
+
290
309
  Keep the UI in **small focused components** (~2–4K characters each — product card, gallery, cart panel, address step, payment step…), not monolithic page files: smaller files are faster to emit, review and fix.
291
310
 
292
311
  ### 3.1 Product list
@@ -305,7 +324,7 @@ const { products, page, per_page, has_next } = await store.listProducts({
305
324
  }); // sort: -created_date | name | price | -price | popularity | rating
306
325
  ```
307
326
 
308
- Each row is a full product record — for a card use `name`, `images[0]?.src` (**may be empty — render a placeholder, never a broken `<img>`**), `price`, `regular_price`, `on_sale` (sale badge), `short_description`, `stock_status`, `average_rating`/`rating_count` (stars cost no extra call) and `ribbons` (`[{ id, name }]`, may be absent — labels like "Best Seller" for the card corner). **There is no product type flag**: `product.attributes?.length > 0` means the product sells variants and its `price` is a *from*-price rolled up from the cheapest variant — render it as "From …". Categories for the nav come from `store.listCategories()` (a tree via `parent_id`). That is the whole card — no other call or reference needed for the list view.
327
+ Each row is a full product record — for a card use `name`, `images[0]?.src` (**may be empty — render a placeholder, never a broken `<img>`**), `price`, `regular_price`, `on_sale` (sale badge), `short_description`, `stock_status`, `average_rating`/`rating_count` (stars cost no extra call) and `ribbons` (`[{ id, name }]`, may be absent — labels like "Best Seller" for the card corner). **There is no product type flag**: `product.attributes?.length > 0` means the product sells variants and its `price` is a *from*-price rolled up from the cheapest variant — render it as "From …". Categories for the nav come from `store.listCategories()` — an **array** of root categories with subcategories nested under `children` (map over it directly; there is no `{ categories }` wrapper on the result). That is the whole card — no other call or reference needed for the list view.
309
328
 
310
329
  **Carry forward:** each card links to the product page by **`slug`**.
311
330
 
@@ -348,6 +367,8 @@ A product with attributes is **rejected without a `variation_id`** (`400 variati
348
367
  ```js
349
368
  let cart = await store.getCart(); // null → no cart yet (an expired token self-clears)
350
369
  // cart.items → [{ item_key, name, image, quantity, price, subtotal, total, attributes, purchasable }]
370
+ // item.attributes is an ARRAY of { name, option } — render "Size: 42" by
371
+ // mapping it (attributes.map(a => `${a.name}: ${a.option}`)); it is never a map
351
372
  // cart.totals → { subtotal, discount_total, shipping_total, cart_tax, total_tax, total, … }
352
373
  // cart.coupon_notices / cart.removed_items → tell the customer what auto-dropped and why
353
374
  //
@@ -444,7 +465,7 @@ Two shapes to get right when rendering: **`order` carries flat totals** — `ord
444
465
 
445
466
  ## 4. Done when
446
467
 
447
- Post-installation is complete when every line below holds — verify against this list instead of re-reading docs:
468
+ Post-installation is complete when every line below holds — check against this list instead of re-reading docs. **Do not validate the admin part at all**: everything under `/store-admin` ships finished and already tested — there is nothing there for you to verify.
448
469
 
449
470
  - [ ] `/store-admin/*` mounted behind the shipped `AuthGuard`; `/` routes somewhere real (storefront or a redirect).
450
471
  - [ ] `commerce/seed-store` ran once and reported the catalog — real products, final image URLs; if the user restricted payment methods, `payment_methods` was passed in that same call.
@@ -452,8 +473,7 @@ Post-installation is complete when every line below holds — verify against thi
452
473
  - [ ] Product page renders from `store.getProduct` with one selector per attribute, resolving to `view.addToCart`.
453
474
  - [ ] The storefront talks to the API through **one `createStorefront` instance** — no hand-rolled `cart_token` handling, and `payment_gateways` read from `getStoreInfo()` only, never off a cart.
454
475
  - [ ] `/order-received` calls `completeReturn` and renders `paid` / `unpaid` / `cancelled`.
455
- - [ ] Card payments: `card-payment.ts` implemented (Stripe: paste §2.2), `STRIPE_SECRET_KEY` secret set, webhook endpoint registered — **or** the card gateway disabled (seed `payment_methods` without `"card"`).
456
- - [ ] **Verified by driving a real test order end to end** — add to cart → address → shipping → `place-order` → `/order-received` renders the right state — then the test order cancelled/deleted in the admin. Placing and cleaning up a test order is routine install verification, not a destructive action. A button existing is **not** evidence the flow works; only the driven flow is.
476
+ - [ ] Online payments **decided, not defaulted** (§2.2): if the store takes card payments, `card-payment.ts` implemented (Stripe: paste §2.2), `STRIPE_SECRET_KEY` secret set and webhook endpoint registered — wired at whatever point in the flow you judged right; if it doesn't (or not yet), the card gateway disabled (seed `payment_methods` without `"card"`). Either way, checkout never offers a card option with no provider behind it.
457
477
 
458
478
  ## 5. Next
459
479
 
@@ -14,6 +14,7 @@ import { ChevronDown, ChevronRight, ChevronUp, Loader2, Plus, Settings2, Trash2
14
14
 
15
15
  import { base44, call } from "../../../lib/api";
16
16
  import SearchSelect from "../../../components/SearchSelect";
17
+ import ConfirmDialog from "../../../components/ConfirmDialog";
17
18
  import { slugify } from "../../../lib/product-utils";
18
19
 
19
20
  /** Sentinel option value for "create the thing I just typed". */
@@ -35,12 +36,16 @@ const fetchValues = (attributeId) =>
35
36
  * immediately, while the choice of which values *this* product uses is part of
36
37
  * the product form and saves with it.
37
38
  */
38
- export default function AttributesSection({ product, up, refs }) {
39
+ export default function AttributesSection({ product, up, refs, variations = [] }) {
39
40
  const attributes = product.attributes || [];
40
41
  const globalAttributes = refs.attributes || [];
41
42
  const [valuesByAttr, setValuesByAttr] = useState({});
42
43
  const [manageOpen, setManageOpen] = useState(false);
43
44
  const [busy, setBusy] = useState(false);
45
+ // Destructive edits (removing a value or an option) confirm before applying,
46
+ // because the variant list is rebuilt from these and rows are deleted.
47
+ const [pendingValues, setPendingValues] = useState(null); // {index, options, removed, affected, stock}
48
+ const [pendingRemoveRow, setPendingRemoveRow] = useState(null); // index
44
49
 
45
50
  const loadValues = useCallback(async (attributeId) => {
46
51
  const rows = await fetchValues(attributeId);
@@ -68,6 +73,17 @@ export default function AttributesSection({ product, up, refs }) {
68
73
  const removeRow = (index) =>
69
74
  up({ attributes: attributes.filter((_, i) => i !== index).map((a, i) => ({ ...a, position: i })) });
70
75
 
76
+ /** Variants that carry one of the removed values for this option. */
77
+ const affectedBy = (row, removed) =>
78
+ variations.filter((v) =>
79
+ (v.attributes || []).some(
80
+ (a) =>
81
+ (a.attribute_id || a.name) === (row.attribute_id || row.name) && removed.includes(a.option)
82
+ )
83
+ );
84
+ const stockOf = (list) =>
85
+ list.reduce((sum, v) => sum + (v.manage_stock === "yes" ? Number(v.stock_quantity) || 0 : 0), 0);
86
+
71
87
  const moveRow = (index, delta) => {
72
88
  const target = index + delta;
73
89
  if (target < 0 || target >= attributes.length) return;
@@ -125,20 +141,25 @@ export default function AttributesSection({ product, up, refs }) {
125
141
  <div className="space-y-3">
126
142
  <div className="flex items-start justify-between gap-4">
127
143
  <div>
128
- <h3 className="text-sm font-semibold">Attributes</h3>
144
+ <h3 className="text-sm font-semibold">Options</h3>
129
145
  <p className="text-xs text-muted-foreground">
130
- What this product varies by. Every attribute is an axis, and the variants below follow
131
- the values you pick.
146
+ What this product comes in — each combination of values becomes a variant below.
132
147
  </p>
133
148
  </div>
134
- <Button type="button" variant="outline" size="sm" onClick={() => setManageOpen(true)}>
135
- <Settings2 className="mr-2 h-4 w-4" /> Manage attributes…
149
+ <Button
150
+ type="button"
151
+ variant="ghost"
152
+ size="icon"
153
+ title="Manage option catalog (affects all products)"
154
+ onClick={() => setManageOpen(true)}
155
+ >
156
+ <Settings2 className="h-4 w-4 text-muted-foreground" />
136
157
  </Button>
137
158
  </div>
138
159
 
139
160
  {attributes.length === 0 && (
140
161
  <p className="text-sm text-muted-foreground">
141
- None — this product sells as a single item. Add one (Size, Color) to sell variants.
162
+ No options yet. Add one (Size, Color, Pack…) and pick the values this product comes in.
142
163
  </p>
143
164
  )}
144
165
 
@@ -173,7 +194,11 @@ export default function AttributesSection({ product, up, refs }) {
173
194
  type="button"
174
195
  variant="ghost"
175
196
  size="icon"
176
- onClick={() => removeRow(i)}
197
+ onClick={() =>
198
+ (row.options || []).length && variations.length
199
+ ? setPendingRemoveRow(i)
200
+ : removeRow(i)
201
+ }
177
202
  title="Remove from this product"
178
203
  >
179
204
  <Trash2 className="h-4 w-4 text-muted-foreground" />
@@ -185,8 +210,18 @@ export default function AttributesSection({ product, up, refs }) {
185
210
  value={selected.map((o) => ({ value: o, label: o }))}
186
211
  onChange={(next) => {
187
212
  const created = next.find((o) => o.value === CREATE);
188
- if (created) createAndSelectValue(row, i, createdName(created.label));
189
- else setRow(i, { options: next.map((o) => o.value) });
213
+ if (created) {
214
+ createAndSelectValue(row, i, createdName(created.label));
215
+ return;
216
+ }
217
+ const options = next.map((o) => o.value);
218
+ const removed = selected.filter((o) => !options.includes(o));
219
+ const affected = removed.length ? affectedBy(row, removed) : [];
220
+ if (affected.length) {
221
+ setPendingValues({ index: i, options, removed, affected, stock: stockOf(affected) });
222
+ } else {
223
+ setRow(i, { options });
224
+ }
190
225
  }}
191
226
  search={async (q) => {
192
227
  const term = (q || "").trim();
@@ -205,7 +240,7 @@ export default function AttributesSection({ product, up, refs }) {
205
240
  <div className="flex items-center gap-2">
206
241
  <SearchSelect
207
242
  className="w-72"
208
- placeholder="Add attribute…"
243
+ placeholder="Add option…"
209
244
  value={null}
210
245
  onChange={(opt) => {
211
246
  if (!opt) return;
@@ -235,6 +270,40 @@ export default function AttributesSection({ product, up, refs }) {
235
270
  onChanged={refs.refreshAttributes}
236
271
  />
237
272
  )}
273
+
274
+ {pendingValues && (
275
+ <ConfirmDialog
276
+ open
277
+ onOpenChange={(o) => !o && setPendingValues(null)}
278
+ title={`Remove ${pendingValues.removed.map((r) => `“${r}”`).join(", ")}?`}
279
+ description={`This deletes ${pendingValues.affected.length} variant(s)${
280
+ pendingValues.stock ? ` and their ${pendingValues.stock} units of stock` : ""
281
+ }. Their prices, stock and SKUs will be lost when you save.`}
282
+ confirmLabel="Delete variants"
283
+ onConfirm={() => {
284
+ setRow(pendingValues.index, { options: pendingValues.options });
285
+ setPendingValues(null);
286
+ }}
287
+ />
288
+ )}
289
+
290
+ {pendingRemoveRow != null && (
291
+ <ConfirmDialog
292
+ open
293
+ onOpenChange={(o) => !o && setPendingRemoveRow(null)}
294
+ title={`Remove the “${attributes[pendingRemoveRow]?.name}” option?`}
295
+ description={
296
+ attributes.length === 1
297
+ ? `This deletes all ${variations.length} variant(s) with their prices, stock and SKUs.`
298
+ : `The variant list is rebuilt without it — prices, stock and SKUs of the current ${variations.length} variant(s) may be lost.`
299
+ }
300
+ confirmLabel="Remove option"
301
+ onConfirm={() => {
302
+ removeRow(pendingRemoveRow);
303
+ setPendingRemoveRow(null);
304
+ }}
305
+ />
306
+ )}
238
307
  </div>
239
308
  );
240
309
  }
@@ -329,10 +398,10 @@ function ManageAttributesDialog({ attributes, valuesByAttr, loadValues, onClose,
329
398
  <Dialog open onOpenChange={(o) => !o && onClose()}>
330
399
  <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
331
400
  <DialogHeader>
332
- <DialogTitle>Manage attributes</DialogTitle>
401
+ <DialogTitle>Option catalog</DialogTitle>
333
402
  </DialogHeader>
334
403
  <p className="-mt-2 text-xs text-muted-foreground">
335
- Shared across every product — changes save immediately. To delete an attribute or a value,
404
+ Shared across every product — changes save immediately. To delete an option or a value,
336
405
  remove it from the entity data.
337
406
  </p>
338
407
 
@@ -398,12 +467,12 @@ function ManageAttributesDialog({ attributes, valuesByAttr, loadValues, onClose,
398
467
  )}
399
468
  </div>
400
469
  ))}
401
- {!attributes.length && <p className="text-sm text-muted-foreground">No attributes yet.</p>}
470
+ {!attributes.length && <p className="text-sm text-muted-foreground">No options yet.</p>}
402
471
  </div>
403
472
 
404
473
  <div className="flex items-end gap-2 border-t pt-3">
405
474
  <div className="flex-1 space-y-1.5">
406
- <Label>New attribute</Label>
475
+ <Label>New option</Label>
407
476
  <Input
408
477
  placeholder="e.g. Fabric"
409
478
  value={newName}
@@ -1,9 +1,7 @@
1
1
  import React from "react";
2
2
  import { Card, CardContent, CardHeader } from "@/components/ui/card";
3
3
  import { Checkbox } from "@/components/ui/checkbox";
4
- import { Separator } from "@/components/ui/separator";
5
4
 
6
- import { isVariable } from "../../../lib/product-utils";
7
5
  import PriceInventoryTab from "./tabs/PriceInventoryTab";
8
6
  import ModifiersTab from "./tabs/ModifiersTab";
9
7
  import LinkedTab from "./tabs/LinkedTab";
@@ -31,7 +29,7 @@ export default function ProductDataPanel({ product, up, refs, variations, setVar
31
29
  />
32
30
  ),
33
31
  },
34
- { id: "modifiers", label: "Modifiers", body: <ModifiersTab product={product} up={up} /> },
32
+ { id: "modifiers", label: "Specifications", body: <ModifiersTab product={product} up={up} /> },
35
33
  ...(product.downloadable
36
34
  ? [{ id: "downloads", label: "Downloads", body: <DownloadsTab product={product} up={up} /> }]
37
35
  : []),
@@ -68,14 +66,6 @@ export default function ProductDataPanel({ product, up, refs, variations, setVar
68
66
  </section>
69
67
  ))}
70
68
  </div>
71
- <Separator />
72
- <div className="px-4 py-2 text-xs text-muted-foreground">
73
- {isVariable(product)
74
- ? `${(product.attributes || []).length} variant attribute(s) · ${(variations || []).length} variant(s)`
75
- : "No variants"}
76
- {product.virtual ? " · Virtual" : ""}
77
- {product.downloadable ? " · Downloadable" : ""}
78
- </div>
79
69
  </CardContent>
80
70
  </Card>
81
71
  );
@@ -4,16 +4,22 @@ import MetaDataEditor from "../../../../components/MetaDataEditor";
4
4
 
5
5
  /**
6
6
  * Descriptive properties — Material, Care, GTIN. They are `meta_data`, never
7
- * attributes: they don't create variants and never affect price or stock.
7
+ * options: they don't create variants and never affect price or stock.
8
8
  */
9
9
  export default function ModifiersTab({ product, up }) {
10
+ const empty = !(product.meta_data || []).length;
10
11
  return (
11
12
  <div className="max-w-xl space-y-2">
12
13
  <p className="text-xs text-muted-foreground">
13
- Shown as product details. They have no effect on price, stock or variants — what a product
14
- varies by belongs in Attributes, under Price &amp; Inventory.
14
+ Descriptive details shown on the product page (e.g. ingredients, allergens, care
15
+ instructions).
15
16
  </p>
16
17
  <MetaDataEditor value={product.meta_data || []} onChange={(meta_data) => up({ meta_data })} />
18
+ {empty && (
19
+ <p className="text-xs text-muted-foreground">
20
+ Want the product to vary by something? Add it as an option under Price &amp; Inventory.
21
+ </p>
22
+ )}
17
23
  </div>
18
24
  );
19
25
  }