@base44/app-plugin-commerce 0.1.14 → 0.1.15

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.14",
3
+ "version": "0.1.15",
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, 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).
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. Its §0 schedules the whole install: **storefront components are written while image generation, the ~20 s seed call and payment wiring run — never after them**. 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
 
@@ -4,6 +4,20 @@ What to do right after the static installation ([`installation-guidelines.md`](.
4
4
 
5
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
+ ## 0. Sequencing — build the UI while the slow work runs
8
+
9
+ The sections below are ordered for **reading** — they are not a schedule. Executed strictly top-to-bottom, the install spends most of its wall-clock waiting on things that never needed to block: image generation (the slowest step of the whole install), the `commerce/seed-store` call (~20 seconds), and — for stores that take card payments — the user round-trip for the provider secret key. **None of these block writing the storefront**: every request/response shape §3 builds against is documented on this page, so the components are written from the docs, not from live data. Live data is only needed at the very end, to verify.
10
+
11
+ So interleave — whenever a slow activity is in flight, write UI instead of waiting:
12
+
13
+ 1. **Start image generation first** — kick off every product image before anything else (§2.1 "Images"), because it takes the longest and nothing depends on it until seed time.
14
+ 2. **Mount the admin router (§1)** — minutes of work — and start writing storefront components (§3) while the images render.
15
+ 3. **The moment the image URLs are back, fire `commerce/seed-store` (§2) and keep writing UI while it runs.** Don't idle on the call; pick up its response (catalog report, slugs) when you next need it. If your tooling runs calls in the background, use that; if not, order the work so the call sits between two chunks of component-writing, never between you and an empty wait.
16
+ 4. **Payment wiring (§2.2), if the store takes card payments, runs in parallel too** — the file paste and webhook registration touch nothing the storefront depends on, and the ask for the secret key can be pending while you build.
17
+ 5. **Converge at the end**: with the seed done and pages written, verify the storefront against the live catalog and walk the [Done when](#4-done-when) checklist.
18
+
19
+ The only real dependency edges are: image URLs → seed payload, and seed done → final verification. Everything else overlaps.
20
+
7
21
  ---
8
22
 
9
23
  ## 1. Embedding the admin pages
@@ -46,7 +60,7 @@ Even if the client guard were bypassed, layers 2 and 3 keep the store data safe.
46
60
 
47
61
  ## 2. Store data — seeding
48
62
 
49
- A fresh install has **no settings and no catalog**. One call to `commerce/seed-store` (admin-only, idempotent) initializes both. It always creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`; USD, kg/cm, taxes off prices) and the `offline` and `card` payment gateways — and, depending on the payload, the catalog. A fallback "Rest of the world" **Shipping & Tax Location** (one free shipping rate, no tax) is seeded **only when the payload carries no `locations`** — locations you pass are the store's only shipping data, with no seeded fallback beside them. Pass **`currency`** (an ISO code, e.g. `"EUR"`) and/or **`weight_unit`**/**`dimension_unit`** to set the store's currency and measurement units instead of the defaults — explicit values always win, on a first seed and a re-run alike. (Prices are *formatted* with `Intl.NumberFormat` per the viewer's locale — the currency is a value; there are no format settings.)
63
+ A fresh install has **no settings and no catalog**. One call to `commerce/seed-store` (admin-only, idempotent) initializes both. The call takes **~20 seconds** — never sit through it: fire it and write storefront components while it runs (§0); nothing in §3 needs its response, only the final verification does. It always creates the business defaults — the six settings groups (`general`, `products`, `inventory`, `tax`, `shipping`, `emails`; USD, kg/cm, taxes off prices) and the `offline` and `card` payment gateways — and, depending on the payload, the catalog. A fallback "Rest of the world" **Shipping & Tax Location** (one free shipping rate, no tax) is seeded **only when the payload carries no `locations`** — locations you pass are the store's only shipping data, with no seeded fallback beside them. Pass **`currency`** (an ISO code, e.g. `"EUR"`) and/or **`weight_unit`**/**`dimension_unit`** to set the store's currency and measurement units instead of the defaults — explicit values always win, on a first seed and a re-run alike. (Prices are *formatted* with `Intl.NumberFormat` per the viewer's locale — the currency is a value; there are no format settings.)
50
64
 
51
65
  Pass **`payment_methods`** (gateway slugs, e.g. `["card"]`) when the user restricts how they get paid: the listed gateways are enabled and **every other gateway row is disabled** — "card-only" or "offline-only" is part of the same seed call, with **no `commerce.PaymentGateway` reads or writes of your own**. Explicit values win on re-runs too. Unknown slugs fail as `400 invalid_payload` (the error lists the known ones). Should you ever need direct entity access, names are dotted — bracket syntax only: `base44.entities["commerce.PaymentGateway"]` (`commerce__PaymentGateway` / `PaymentGateway` don't exist).
52
66
 
@@ -137,7 +151,7 @@ The response reports everything:
137
151
  "payment_methods": { "enabled": ["card"], "disabled": ["offline"] } } // null when not passed
138
152
  ```
139
153
 
140
- **Images**: every product needs at least one, and the URL you seed is the URL the store serves — there are no placeholders to swap later. So resolve each image to its **final URL before seeding**: use whatever image generation the app has available and store the returned URL, upload real assets with `base44.integrations.Core.UploadFile({ file })` → public URL, or fall back to stable public stock URLs (`base44/functions/commerce/seed-store/sample-data.ts` shows a working Unsplash pattern). Match the image to the product. Image generation is usually the **slowest step of the whole install** — kick all product images off first, do the rest (router mount, payment file, storefront pages) while they render, and seed once the URLs are back. If an image isn't ready at seed time, seed without it and set it afterwards through the admin API — don't seed a dead path and compensate in the frontend.
154
+ **Images**: every product needs at least one, and the URL you seed is the URL the store serves — there are no placeholders to swap later. So resolve each image to its **final URL before seeding**: use whatever image generation the app has available and store the returned URL, upload real assets with `base44.integrations.Core.UploadFile({ file })` → public URL, or fall back to stable public stock URLs (`base44/functions/commerce/seed-store/sample-data.ts` shows a working Unsplash pattern). Match the image to the product. **Final means permanent and resolving**, and both fail silently later rather than at seed time, so check them now: an image tool that returns a *temporary or signed* URL (expiry params in the query string are the tell) must be re-hosted — download and `UploadFile` it for a stable public URL — and before seeding, spot-check that the URLs actually resolve (fetch one or two: HTTP 200, image content type). A dead or expiring URL seeds fine and then renders as a broken image or an eternal placeholder in the store. Image generation is usually the **slowest step of the whole install** — this is dependency edge #1 of §0: kick all product images off first, do the rest (router mount, payment file, storefront pages) while they render, and seed the moment the URLs are back — then keep writing UI through the seed call too. If an image isn't ready at seed time you *may* seed without it and set it afterwards through the admin API (never seed a dead path and compensate in the frontend) — but that is **an open debt, not a resolution**: track every product seeded imageless and close it before handover. The [Done when](#4-done-when) checklist fails while any product shows a placeholder for lack of a real image.
141
155
 
142
156
  A successful response means the data is in — the catalog and settings are live exactly as reported. Write any remaining store-specific settings into `commerce.StoreSettings` (direct CRUD, one record per `group_id` — weight/dimension units are the usual ones; patch `values`, don't replace groups you weren't asked about).
143
157
 
@@ -148,7 +162,7 @@ The order side of payments is **already implemented** (checkout routing, confirm
148
162
  **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
163
 
150
164
  - **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).
165
+ - **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 — the file paste and webhook registration touch nothing the storefront depends on, so it **runs in parallel with the storefront build** (§0) rather than before or after it. The one part that involves the user is the provider secret key: ask when it makes sense in the conversation, and keep building while the answer is pending — never stall the install on it. 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
166
  - **Unclear from the request** → build everything else first and raise payments with the user at the end, or ask.
153
167
 
154
168
  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.
@@ -280,7 +294,7 @@ That's it — checkout redirect, `/order-received` confirmation, the webhook, th
280
294
 
281
295
  ## 3. Storefront quick start — logic only
282
296
 
283
- No visitor UI ships; the storefront **API** is complete. The four chunks below are the whole happy path — product list → product page → cart → checkout — showing what to call, what comes back, and what to carry into the next step. Open [`docs/api-storefront.md`](./docs/api-storefront.md) only for what's beyond them (attribute/price filters, reviews, customer accounts, refunds), and [`references/product-render.md`](./references/product-render.md) for which fields belong in which view.
297
+ No visitor UI ships; the storefront **API** is complete. The four chunks below are the whole happy path — product list → product page → cart → checkout — showing what to call, what comes back, and what to carry into the next step. **None of it waits on the seed**: every shape you build against is documented right here, so write these components while `seed-store` and image generation are still running (§0) — live data is only needed to verify the finished pages. Open [`docs/api-storefront.md`](./docs/api-storefront.md) only for what's beyond them (attribute/price filters, reviews, customer accounts, refunds), and [`references/product-render.md`](./references/product-render.md) for which fields belong in which view.
284
298
 
285
299
  Build on the **shipped API client** — create it once and import that instance everywhere (wrapping it in a React context is fine; never a second copy):
286
300
 
@@ -344,6 +358,11 @@ const view = resolveSelection(product, variations, selection);
344
358
  // view.axes → [{ key, name, options }] — render one control each
345
359
  // view.availability → { [axisKey]: { [option]: "available" | "out_of_stock" | "unavailable" } }
346
360
  // view.display → { price, regular_price, on_sale, sku, stock_status, image, … } for the selection
361
+ // ⚠ display.image is an OBJECT — { src, name, alt } | null. Render
362
+ // <img src={view.display.image?.src} alt={view.display.image?.alt}>.
363
+ // Passing the object itself as src fails the load and your fallback
364
+ // shows a placeholder for every product — with the real image sitting
365
+ // one `.src` away. Same shape everywhere: product.images[n].src too.
347
366
  // view.purchasable → gate the Add-to-cart button on this
348
367
  // view.addToCart → { product_id, variation_id } — null until the selection resolves
349
368
  ```
@@ -356,7 +375,7 @@ const cart = await store.addItem({ ...view.addToCart, quantity: 1 });
356
375
 
357
376
  A product with attributes is **rejected without a `variation_id`** (`400 variation_required`) — that is why `view.addToCart` and not a bare `product_id` goes into the call.
358
377
 
359
- **What the page renders — all from this one `get-product` call, no extra reads:** a gallery from `product.images` (`view.display.image` is the variant-selected one; placeholder when empty), name, price from `view.display` (`price`/`regular_price`/`on_sale` → sale badge), one selector per axis, stock state, `short_description` then `description` (**both HTML — render as rich text, don't escape or truncate away the markup**), SKU, `categories` as a breadcrumb, `ribbons` as light labels near the metadata, the `reviews` block (`{ items, has_next, average_rating, rating_count }`), and the `upsells`/`cross_sells` summaries. Descriptive properties (Material, Care…) live in `product.meta_data` — render them as a spec table; they are not attributes and not ribbons. That is the complete product page — [`references/storefront-product-page.md`](./references/storefront-product-page.md) and [`references/product-render.md`](./references/product-render.md) are only for edge cases and for adding fields to the *listing* call.
378
+ **What the page renders — all from this one `get-product` call, no extra reads:** a gallery from `product.images` (`view.display.image` is the variant-selected one; placeholder **only** when the product truly has no images — every image is an `{ src, alt }` object, so render `img.src`/`img.alt`, never the object itself), name, price from `view.display` (`price`/`regular_price`/`on_sale` → sale badge), one selector per axis, stock state, `short_description` then `description` (**both HTML — render as rich text, don't escape or truncate away the markup**), SKU, `categories` as a breadcrumb, `ribbons` as light labels near the metadata, the `reviews` block (`{ items, has_next, average_rating, rating_count }`), and the `upsells`/`cross_sells` summaries. Descriptive properties (Material, Care…) live in `product.meta_data` — render them as a spec table; they are not attributes and not ribbons. That is the complete product page — [`references/storefront-product-page.md`](./references/storefront-product-page.md) and [`references/product-render.md`](./references/product-render.md) are only for edge cases and for adding fields to the *listing* call.
360
379
 
361
380
  **Carry forward:** nothing — the client keeps the `cart_token`.
362
381
 
@@ -471,6 +490,7 @@ Post-installation is complete when every line below holds — check against this
471
490
  - [ ] `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.
472
491
  - [ ] Product list renders from `store.listProducts` (cards: image/placeholder, name, price or "From …", sale badge, stars, ribbons) and links by `slug`.
473
492
  - [ ] Product page renders from `store.getProduct` with one selector per attribute, resolving to `view.addToCart`.
493
+ - [ ] **Real images actually render** — no placeholder anywhere except for a product genuinely without images: every image rendered via `.src` (`images[n].src`, `view.display.image?.src` — they are objects, not URL strings), every seeded URL permanent and resolving, and any product deliberately seeded imageless (§2.1) since given its image.
474
494
  - [ ] 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.
475
495
  - [ ] `/order-received` calls `completeReturn` and renders `paid` / `unpaid` / `cancelled`.
476
496
  - [ ] 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.
@@ -21,7 +21,7 @@ A listing **row** is the product record itself (minus paywalled fields), plus re
21
21
  |---|---|---|---|
22
22
  | `id`, `name`, `slug`, `status` | ✅ | ✅ | There is no `type` field — a product sells variants when `attributes[]` is non-empty |
23
23
  | `price`, `regular_price`, `sale_price`, `on_sale` | ✅ | ✅ | With variants the parent `price` is a starting point, not the truth — see §3 |
24
- | `images[]`, `featured`, `short_description`, `description` | ✅ | ✅ | Cards normally use `images[0]` + `short_description`. `images` can be **empty** — render a placeholder, don't leave a broken `img` (on the product page `resolveSelection`'s `display.image` is `null` in that same case) |
24
+ | `images[]`, `featured`, `short_description`, `description` | ✅ | ✅ | Cards normally use `images[0]` + `short_description`. Every entry is an **object** `{ src, name, alt, position }` — render `images[0]?.src`, never the entry itself (an object passed as `src` fails the load and your fallback shows a placeholder despite the image existing). Sub-fields aren't guaranteed on raw entries — the `normalizeImage` helper (`@/commerce/utils`) returns `{src, name, alt}` with a non-empty `src` or `null`. `images` can also be genuinely **empty** — render a placeholder, don't leave a broken `img` (on the product page `resolveSelection`'s `display.image` is `null` in that same case, already normalized) |
25
25
  | `sku`, `stock_status`, `stock_quantity`, `manage_stock`, `backorders` | ✅ | ✅ | |
26
26
  | `average_rating`, `rating_count`, `total_sales` | ✅ | ✅ | Enough for stars on a card; the reviews themselves are not in a row |
27
27
  | `downloadable`, `virtual`, `weight`, `dimensions`, `attributes[]`, `meta_data` | ✅ | ✅ | `attributes[]` is the variant axes; `meta_data` is the descriptive properties the admin calls **modifiers** |
@@ -36,7 +36,7 @@ Bind the UI to `view` — not to `product.*` — so a selection actually changes
36
36
  | `view` field | Binds to |
37
37
  |---|---|
38
38
  | `axes` | the controls: `[{ key, name, attribute_id, options[] }]`, dead options already dropped |
39
- | `display` | image, price, `regular_price`, `on_sale`, SKU, stock, weight/dimensions, description — variation-first with parent fallback. `display.image` is `{src, name, alt}`, or **`null` when the product has no images** — the one case needing a placeholder in the UI |
39
+ | `display` | image, price, `regular_price`, `on_sale`, SKU, stock, weight/dimensions, description — variation-first with parent fallback. `display.image` is an **object** — always the normalized `{src, name, alt}` (`src` guaranteed non-empty, `alt` defaulted; stored entries are not uniform, so never read `variation.image`/`product.images` raw when this field exists), or **`null` when neither the variation nor the product has a renderable image** (the one case needing a placeholder in the UI). Render `display.image?.src` / `display.image?.alt`; **passing the object itself to an `<img src>` or Image component fails the load and shows the placeholder for every product**. For galleries, run raw entries through the exported `normalizeImage`: `product.images.map(normalizeImage).filter(Boolean)` |
40
40
  | `availability[axisKey][option]` | per-option state: `"available"` / `"out_of_stock"` / `"unavailable"` — §3 |
41
41
  | `priceRange` | `{ min, max, on_sale, count }` while the selection is incomplete — §3 |
42
42
  | `complete` / `missingAxes` | the button label: `Select a ${view.missingAxes[0]?.name}` |
@@ -74,7 +74,7 @@ Handle `400 variation_required` (empty `variation_id` on a product with attribut
74
74
 
75
75
  - [ ] One control per axis in `view.axes` — no combination list anywhere in the UI.
76
76
  - [ ] `meta_data` modifiers render as specs, not selectors.
77
- - [ ] Image, price, SKU and availability all come from `view.display`, with a placeholder for `display.image === null`.
77
+ - [ ] Image, price, SKU and availability all come from `view.display`; the image is rendered from `display.image.src` (it's an object, not a URL string), with a placeholder **only** for `display.image === null`.
78
78
  - [ ] Incomplete selection shows `view.priceRange` — never `$0`, never the parent `price`.
79
79
  - [ ] `"unavailable"` disabled, `"out_of_stock"` labelled; every click routed through `selectOption`.
80
80
  - [ ] Initial state from `defaultSelection`; selection mirrored into the URL.
@@ -264,13 +264,31 @@ export function priceRange(product, variations, selection = {}) {
264
264
  return { min: Math.min(...prices), max: Math.max(...prices), on_sale: onSale, count: prices.length };
265
265
  }
266
266
 
267
+ /**
268
+ * Normalize a stored image entry to the one shape the UI renders. Stored images
269
+ * are not uniform — product images carry `position`, variation images don't,
270
+ * and no sub-field is required by the schema — so this guarantees consumers
271
+ * `{src, name, alt}` with a non-empty `src`, or `null` when there is nothing
272
+ * renderable. Run gallery entries through it too: `product.images.map(normalizeImage)`.
273
+ *
274
+ * @param {object|null|undefined} img a stored image entry (`product.images[n]` or `variation.image`)
275
+ * @returns {{src: string, name: string, alt: string}|null}
276
+ */
277
+ export function normalizeImage(img) {
278
+ const src = typeof img?.src === "string" ? img.src.trim() : "";
279
+ if (!src) return null;
280
+ const name = img.name ?? "";
281
+ return { src, name, alt: img.alt || name };
282
+ }
283
+
267
284
  /**
268
285
  * The fields to display for the current state, resolved variation-first with a
269
286
  * fallback to the parent. `image` is the variation's own image when it has one
270
287
  * — highlight it in the gallery rather than replacing the gallery.
271
288
  *
272
- * `image` is `{src, name, alt}`, or `null` when the product has no images at all
273
- * — the one case needing a UI placeholder.
289
+ * `image` is **always** the normalized `{src, name, alt}` (never a raw stored
290
+ * entry — see `normalizeImage`), or `null` when neither the variation nor the
291
+ * product has a renderable image — the one case needing a UI placeholder.
274
292
  *
275
293
  * @returns {{price: number|null, regular_price: number|null, sale_price: number|null,
276
294
  * on_sale: boolean, sku: string, stock_status: string,
@@ -296,7 +314,7 @@ export function displayFields(product, variation) {
296
314
  ? (variation.manage_stock === "yes" ? (variation.stock_quantity ?? null) : (product?.stock_quantity ?? null))
297
315
  : (product?.stock_quantity ?? null),
298
316
  backorders: pick("backorders", "no"),
299
- image: variation?.image?.src ? variation.image : (product?.images?.[0] ?? null),
317
+ image: normalizeImage(variation?.image) ?? normalizeImage(product?.images?.[0]),
300
318
  weight: pick("weight"),
301
319
  dimensions: pick("dimensions"),
302
320
  description: variation?.description || product?.description || "",