@base44/app-plugin-commerce 0.1.6 → 0.1.7

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.
Files changed (95) hide show
  1. package/README.md +11 -11
  2. package/base44/agents/commerce/StoreAdmin.jsonc +2 -2
  3. package/base44/entities/commerce.Cart.jsonc +1 -1
  4. package/base44/entities/commerce.Coupon.jsonc +5 -0
  5. package/base44/entities/commerce.Order.jsonc +6 -7
  6. package/base44/entities/commerce.OrderRefund.jsonc +1 -1
  7. package/base44/entities/commerce.PaymentGateway.jsonc +1 -1
  8. package/base44/entities/commerce.Product.jsonc +6 -16
  9. package/base44/entities/{commerce.ProductTag.jsonc → commerce.ProductRibbon.jsonc} +2 -2
  10. package/base44/entities/commerce.ProductVariation.jsonc +1 -9
  11. package/base44/entities/commerce.ShippingTaxLocation.jsonc +85 -0
  12. package/base44/entities/commerce.Webhook.jsonc +1 -1
  13. package/base44/functions/commerce/admin-orders/helpers.ts +7 -13
  14. package/base44/functions/commerce/admin-products/entry.ts +11 -17
  15. package/base44/functions/commerce/admin-refunds/entry.ts +10 -9
  16. package/base44/functions/commerce/admin-reports/entry.ts +3 -3
  17. package/base44/functions/commerce/admin-tools/entry.ts +9 -36
  18. package/base44/functions/commerce/payment-webhook/entry.ts +50 -89
  19. package/base44/functions/commerce/payments/entry.ts +46 -42
  20. package/base44/functions/commerce/seed-store/defaults.ts +35 -42
  21. package/base44/functions/commerce/seed-store/entry.ts +68 -46
  22. package/base44/functions/commerce/seed-store/sample-data.ts +2 -15
  23. package/base44/functions/commerce/seed-store/seed-catalog.ts +105 -55
  24. package/base44/functions/commerce/storefront-cart/cart-pricing.ts +6 -11
  25. package/base44/functions/commerce/storefront-cart/entry.ts +36 -2
  26. package/base44/functions/commerce/storefront-catalog/entry.ts +55 -72
  27. package/base44/functions/commerce/storefront-checkout/cart-pricing.ts +6 -11
  28. package/base44/functions/commerce/storefront-checkout/entry.ts +43 -56
  29. package/base44/shared/commerce/card-payment.ts +80 -0
  30. package/base44/shared/commerce/coupons.ts +16 -10
  31. package/base44/shared/commerce/emails.ts +30 -18
  32. package/base44/shared/commerce/money.ts +11 -24
  33. package/base44/shared/commerce/payments.ts +55 -286
  34. package/base44/shared/commerce/scan.ts +1 -1
  35. package/base44/shared/commerce/sequence.ts +1 -1
  36. package/base44/shared/commerce/settings.ts +4 -9
  37. package/base44/shared/commerce/shipping.ts +65 -133
  38. package/base44/shared/commerce/tax.ts +48 -96
  39. package/base44/shared/commerce/totals.ts +77 -91
  40. package/package.json +1 -1
  41. package/scripts/install.js +28 -7
  42. package/skills/commerce/SKILL.md +14 -14
  43. package/skills/commerce/docs/api-admin.md +23 -26
  44. package/skills/commerce/docs/api-storefront.md +67 -61
  45. package/skills/commerce/installation-guidelines.md +8 -8
  46. package/skills/commerce/post-installation.md +60 -39
  47. package/skills/commerce/references/admin-product-form.md +15 -12
  48. package/skills/commerce/references/emails.md +2 -2
  49. package/skills/commerce/references/guest-access-security.md +2 -2
  50. package/skills/commerce/references/online-payments.md +24 -166
  51. package/skills/commerce/references/product-render.md +18 -18
  52. package/skills/commerce/references/reviews.md +14 -8
  53. package/skills/commerce/references/storefront-product-page.md +1 -1
  54. package/src/commerce/admin/README.md +4 -5
  55. package/src/commerce/admin/bot/Markdown.jsx +1 -1
  56. package/src/commerce/admin/hooks/useMoney.js +13 -22
  57. package/src/commerce/admin/layout/AuthGuard.jsx +1 -1
  58. package/src/commerce/admin/lib/constants.js +2 -29
  59. package/src/commerce/admin/lib/order-utils.js +1 -1
  60. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +131 -140
  61. package/src/commerce/admin/pages/coupons/CouponsList.jsx +14 -7
  62. package/src/commerce/admin/pages/orders/OrderEditor.jsx +3 -3
  63. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +17 -28
  64. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +5 -11
  65. package/src/commerce/admin/pages/products/Categories.jsx +147 -177
  66. package/src/commerce/admin/pages/products/ProductEditor.jsx +23 -18
  67. package/src/commerce/admin/pages/products/Reviews.jsx +37 -1
  68. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +33 -47
  69. package/src/commerce/admin/pages/products/components/PublishBox.jsx +13 -34
  70. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +29 -29
  71. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +14 -44
  72. package/src/commerce/admin/pages/reports/Reports.jsx +2 -2
  73. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +101 -68
  74. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +40 -38
  75. package/src/commerce/admin/pages/settings/InventorySettings.jsx +1 -41
  76. package/src/commerce/admin/pages/settings/LocationEditor.jsx +377 -0
  77. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +137 -119
  78. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +2 -4
  79. package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +191 -0
  80. package/src/commerce/admin/routes.jsx +6 -12
  81. package/src/commerce/utils/index.js +2 -2
  82. package/src/commerce/utils/shipping-promos.js +45 -49
  83. package/src/commerce/utils/variants.js +1 -1
  84. package/base44/entities/commerce.ShippingClass.jsonc +0 -30
  85. package/base44/entities/commerce.ShippingZone.jsonc +0 -41
  86. package/base44/entities/commerce.ShippingZoneMethod.jsonc +0 -84
  87. package/base44/entities/commerce.TaxClass.jsonc +0 -23
  88. package/base44/entities/commerce.TaxRate.jsonc +0 -68
  89. package/base44/shared/commerce/stripe.ts +0 -463
  90. package/src/commerce/admin/hooks/usePaymentProvider.js +0 -27
  91. package/src/commerce/admin/pages/settings/ProductsSettings.jsx +0 -118
  92. package/src/commerce/admin/pages/settings/ShippingSettings.jsx +0 -304
  93. package/src/commerce/admin/pages/settings/ShippingZoneEditor.jsx +0 -514
  94. package/src/commerce/admin/pages/settings/TaxRatesTable.jsx +0 -231
  95. package/src/commerce/admin/pages/settings/TaxSettings.jsx +0 -280
@@ -3,20 +3,14 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
3
3
  import { Button } from "@/components/ui/button";
4
4
  import { Label } from "@/components/ui/label";
5
5
  import { Checkbox } from "@/components/ui/checkbox";
6
- import {
7
- Select,
8
- SelectContent,
9
- SelectItem,
10
- SelectTrigger,
11
- SelectValue,
12
- } from "@/components/ui/select";
6
+ import { Switch } from "@/components/ui/switch";
13
7
  import { Loader2, Save, Trash2 } from "lucide-react";
14
8
 
15
9
  import ConfirmDialog from "../../../components/ConfirmDialog";
16
- import { PRODUCT_STATUSES, CATALOG_VISIBILITIES } from "../../../lib/constants";
17
10
 
18
11
  /**
19
- * Product editor "Publish" sidebar box.
12
+ * Product editor "Publish" sidebar box. One Visible toggle drives `status`
13
+ * (publish/draft) — there is no separate catalog visibility.
20
14
  * Props: { product, up, onSave, onDelete (null hides delete), saving, dirty }
21
15
  */
22
16
  export default function PublishBox({ product, up, onSave, onDelete, saving, dirty }) {
@@ -33,37 +27,22 @@ export default function PublishBox({ product, up, onSave, onDelete, saving, dirt
33
27
  }
34
28
  };
35
29
 
30
+ const visible = product.status === "publish";
31
+
36
32
  return (
37
33
  <Card>
38
34
  <CardHeader className="pb-2">
39
35
  <CardTitle className="text-base">Publish</CardTitle>
40
36
  </CardHeader>
41
37
  <CardContent className="space-y-3">
42
- <div className="space-y-1.5">
43
- <Label>Status</Label>
44
- <Select value={product.status || "draft"} onValueChange={(v) => up({ status: v })}>
45
- <SelectTrigger><SelectValue /></SelectTrigger>
46
- <SelectContent>
47
- {PRODUCT_STATUSES.map((s) => (
48
- <SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
49
- ))}
50
- </SelectContent>
51
- </Select>
52
- </div>
53
-
54
- <div className="space-y-1.5">
55
- <Label>Catalog visibility</Label>
56
- <Select
57
- value={product.catalog_visibility || "visible"}
58
- onValueChange={(v) => up({ catalog_visibility: v })}
59
- >
60
- <SelectTrigger><SelectValue /></SelectTrigger>
61
- <SelectContent>
62
- {CATALOG_VISIBILITIES.map((v) => (
63
- <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
64
- ))}
65
- </SelectContent>
66
- </Select>
38
+ <div className="flex items-start justify-between gap-4">
39
+ <div>
40
+ <Label>Visible</Label>
41
+ <p className="text-xs text-muted-foreground">
42
+ {visible ? "Shown in the store" : "Hidden — saved as a draft"}
43
+ </p>
44
+ </div>
45
+ <Switch checked={visible} onCheckedChange={(v) => up({ status: v ? "publish" : "draft" })} />
67
46
  </div>
68
47
 
69
48
  <label className="flex cursor-pointer items-center gap-2 text-sm">
@@ -23,13 +23,13 @@ const NO_PARENT = "__none__";
23
23
 
24
24
  /**
25
25
  * Product editor sidebar panel: categories (hierarchical checkboxes + quick-add)
26
- * and tags (multi-select + create-on-enter).
26
+ * and ribbons (multi-select + create-on-enter).
27
27
  *
28
- * Props: { product, up, refs } where refs = { categories, tags, refreshCategories, refreshTags }
28
+ * Props: { product, up, refs } where refs = { categories, ribbons, refreshCategories, refreshRibbons }
29
29
  */
30
30
  export default function TaxonomyPanel({ product, up, refs }) {
31
31
  const categories = refs?.categories || [];
32
- const tags = refs?.tags || [];
32
+ const ribbons = refs?.ribbons || [];
33
33
 
34
34
  return (
35
35
  <>
@@ -39,11 +39,11 @@ export default function TaxonomyPanel({ product, up, refs }) {
39
39
  onChange={(ids) => up({ category_ids: ids })}
40
40
  refresh={refs?.refreshCategories}
41
41
  />
42
- <TagsCard
43
- tags={tags}
44
- selected={product.tag_ids || []}
45
- onChange={(ids) => up({ tag_ids: ids })}
46
- refresh={refs?.refreshTags}
42
+ <RibbonsCard
43
+ ribbons={ribbons}
44
+ selected={product.ribbon_ids || []}
45
+ onChange={(ids) => up({ ribbon_ids: ids })}
46
+ refresh={refs?.refreshRibbons}
47
47
  />
48
48
  </>
49
49
  );
@@ -162,46 +162,46 @@ function CategoriesCard({ categories, selected, onChange, refresh }) {
162
162
  );
163
163
  }
164
164
 
165
- function TagsCard({ tags, selected, onChange, refresh }) {
166
- const [newTag, setNewTag] = useState("");
165
+ function RibbonsCard({ ribbons, selected, onChange, refresh }) {
166
+ const [newRibbon, setNewRibbon] = useState("");
167
167
  const [saving, setSaving] = useState(false);
168
168
 
169
169
  const value = useMemo(
170
170
  () =>
171
171
  selected
172
172
  .map((id) => {
173
- const t = tags.find((x) => x.id === id);
173
+ const t = ribbons.find((x) => x.id === id);
174
174
  return t ? { value: t.id, label: t.name } : null;
175
175
  })
176
176
  .filter(Boolean),
177
- [selected, tags]
177
+ [selected, ribbons]
178
178
  );
179
179
 
180
180
  const search = async (q) =>
181
- tags
181
+ ribbons
182
182
  .filter((t) => !q || (t.name || "").toLowerCase().includes(q.toLowerCase()))
183
183
  .slice(0, 30)
184
184
  .map((t) => ({ value: t.id, label: t.name }));
185
185
 
186
- const createTag = async () => {
187
- const label = newTag.trim();
186
+ const createRibbon = async () => {
187
+ const label = newRibbon.trim();
188
188
  if (!label) return;
189
- // Reuse an existing tag with the same name if present.
190
- const existing = tags.find((t) => (t.name || "").toLowerCase() === label.toLowerCase());
189
+ // Reuse an existing ribbon with the same name if present.
190
+ const existing = ribbons.find((t) => (t.name || "").toLowerCase() === label.toLowerCase());
191
191
  if (existing) {
192
192
  if (!selected.includes(existing.id)) onChange([...selected, existing.id]);
193
- setNewTag("");
193
+ setNewRibbon("");
194
194
  return;
195
195
  }
196
196
  setSaving(true);
197
197
  try {
198
- const created = await base44.entities["commerce.ProductTag"].create({ name: label, count: 0 });
199
- toast.success("Tag created");
200
- setNewTag("");
198
+ const created = await base44.entities["commerce.ProductRibbon"].create({ name: label, count: 0 });
199
+ toast.success("Ribbon created");
200
+ setNewRibbon("");
201
201
  await refresh?.();
202
202
  if (created?.id) onChange([...selected, created.id]);
203
203
  } catch (e) {
204
- toast.error(e.message || "Failed to create tag");
204
+ toast.error(e.message || "Failed to create ribbon");
205
205
  } finally {
206
206
  setSaving(false);
207
207
  }
@@ -210,30 +210,30 @@ function TagsCard({ tags, selected, onChange, refresh }) {
210
210
  return (
211
211
  <Card>
212
212
  <CardHeader className="pb-2">
213
- <CardTitle className="text-base">Tags</CardTitle>
213
+ <CardTitle className="text-base">Ribbons</CardTitle>
214
214
  </CardHeader>
215
215
  <CardContent className="space-y-2">
216
216
  <SearchSelect
217
217
  multiple
218
- placeholder="Select tags"
218
+ placeholder="Select ribbons"
219
219
  value={value}
220
220
  onChange={(next) => onChange(next.map((o) => o.value))}
221
221
  search={search}
222
222
  />
223
223
  <div className="flex gap-2">
224
224
  <Input
225
- value={newTag}
226
- onChange={(e) => setNewTag(e.target.value)}
225
+ value={newRibbon}
226
+ onChange={(e) => setNewRibbon(e.target.value)}
227
227
  onKeyDown={(e) => {
228
228
  if (e.key === "Enter") {
229
229
  e.preventDefault();
230
- createTag();
230
+ createRibbon();
231
231
  }
232
232
  }}
233
- placeholder="Add new tag"
233
+ placeholder="Add new ribbon"
234
234
  className="h-8 text-sm"
235
235
  />
236
- <Button size="sm" variant="outline" onClick={createTag} disabled={saving || !newTag.trim()}>
236
+ <Button size="sm" variant="outline" onClick={createRibbon} disabled={saving || !newRibbon.trim()}>
237
237
  {saving ? <Loader2 className="h-3 w-3 animate-spin" /> : "Add"}
238
238
  </Button>
239
239
  </div>
@@ -24,7 +24,6 @@ import AttributesSection from "../AttributesSection";
24
24
  import { comboKey, generateVariationCombos, variationLabel } from "../../../../lib/product-utils";
25
25
  import { BACKORDER_OPTIONS, STOCK_STATUSES, TAX_STATUSES } from "../../../../lib/constants";
26
26
 
27
- const NONE = "__none__";
28
27
  const ANY = "__any__";
29
28
  const num = (v) => (v === "" ? null : Number(v));
30
29
  const toDateInput = (iso) => (iso ? String(iso).slice(0, 10) : "");
@@ -43,7 +42,7 @@ const COPY_GROUPS = [
43
42
  {
44
43
  key: "shipping",
45
44
  label: "Shipping",
46
- fields: ["weight", "dimensions", "shipping_class_id", "virtual", "downloadable"],
45
+ fields: ["weight", "dimensions", "virtual", "downloadable"],
47
46
  },
48
47
  ];
49
48
 
@@ -60,14 +59,12 @@ const NEW_VARIATION = {
60
59
  download_limit: -1,
61
60
  download_expiry: -1,
62
61
  tax_status: "parent",
63
- tax_class: "",
64
62
  manage_stock: "parent",
65
63
  stock_quantity: null,
66
64
  stock_status: "instock",
67
65
  backorders: "no",
68
66
  weight: null,
69
67
  dimensions: { length: null, width: null, height: null },
70
- shipping_class_id: "",
71
68
  image: null,
72
69
  menu_order: 0,
73
70
  description: "",
@@ -161,10 +158,10 @@ const Section = ({ label, children, className = "" }) => (
161
158
  );
162
159
 
163
160
  /**
164
- * Weight, dimensions and shipping class live per purchasable thing, not per
165
- * product — a large mug weighs more than a small one.
161
+ * Weight and dimensions live per purchasable thing, not per product — a large
162
+ * mug weighs more than a small one.
166
163
  */
167
- function ShippingFields({ values, set, units, classes, inheritLabel }) {
164
+ function ShippingFields({ values, set, units }) {
168
165
  const dims = values.dimensions || {};
169
166
  return (
170
167
  <>
@@ -192,21 +189,6 @@ function ShippingFields({ values, set, units, classes, inheritLabel }) {
192
189
  ))}
193
190
  </div>
194
191
  </div>
195
- <div className="space-y-1.5">
196
- <Label className="text-xs">Shipping class</Label>
197
- <Select
198
- value={values.shipping_class_id || NONE}
199
- onValueChange={(v) => set({ shipping_class_id: v === NONE ? "" : v })}
200
- >
201
- <SelectTrigger><SelectValue /></SelectTrigger>
202
- <SelectContent>
203
- <SelectItem value={NONE}>{inheritLabel}</SelectItem>
204
- {classes.map((c) => (
205
- <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
206
- ))}
207
- </SelectContent>
208
- </Select>
209
- </div>
210
192
  </>
211
193
  );
212
194
  }
@@ -346,13 +328,7 @@ function DefaultRow({ product, up, money, units, refs }) {
346
328
 
347
329
  {!product.virtual && (
348
330
  <Section label="Shipping">
349
- <ShippingFields
350
- values={product}
351
- set={up}
352
- units={units}
353
- classes={refs.shippingClasses || []}
354
- inheritLabel="No shipping class"
355
- />
331
+ <ShippingFields values={product} set={up} units={units} />
356
332
  </Section>
357
333
  )}
358
334
  </div>
@@ -438,13 +414,7 @@ function VariantRow({ variation, onChange, refs, money, units, virtual }) {
438
414
 
439
415
  {!virtual && (
440
416
  <Section label="Shipping">
441
- <ShippingFields
442
- values={variation}
443
- set={set}
444
- units={units}
445
- classes={refs.shippingClasses || []}
446
- inheritLabel="Same as product"
447
- />
417
+ <ShippingFields values={variation} set={set} units={units} />
448
418
  </Section>
449
419
  )}
450
420
 
@@ -491,11 +461,12 @@ function VariantRow({ variation, onChange, refs, money, units, virtual }) {
491
461
  export default function PriceInventoryTab({ product, up, refs, variations, setVariations }) {
492
462
  const money = useMoney();
493
463
  const settings = useSettings();
494
- const weightUnit = settings?.get("products", "weight_unit", "kg");
495
- const dimensionUnit = settings?.get("products", "dimension_unit", "cm");
464
+ const weightUnit = settings?.get("general", "weight_unit", "kg");
465
+ const dimensionUnit = settings?.get("general", "dimension_unit", "cm");
496
466
  const [copyFrom, setCopyFrom] = useState("0");
497
467
  const [copyGroups, setCopyGroups] = useState(["price"]);
498
- const taxClasses = refs.taxClasses || [];
468
+ // Tax groups are named per Shipping & Tax Location; a product picks by name.
469
+ const taxGroups = refs.taxGroups?.length ? refs.taxGroups : ["Products"];
499
470
  const units = { weightUnit, dimensionUnit };
500
471
 
501
472
  const hasAttributes = (product.attributes || []).length > 0;
@@ -547,7 +518,6 @@ export default function PriceInventoryTab({ product, up, refs, variations, setVa
547
518
  backorders: product.backorders,
548
519
  weight: product.weight,
549
520
  dimensions: { ...(product.dimensions || {}) },
550
- shipping_class_id: product.shipping_class_id,
551
521
  };
552
522
 
553
523
  const added = [...wanted.entries()]
@@ -603,12 +573,12 @@ export default function PriceInventoryTab({ product, up, refs, variations, setVa
603
573
  </Select>
604
574
  </div>
605
575
  <div className="space-y-1.5">
606
- <Label>Tax class</Label>
607
- <Select value={product.tax_class || "standard"} onValueChange={(v) => up({ tax_class: v })}>
576
+ <Label>Tax group</Label>
577
+ <Select value={product.tax_group || "Products"} onValueChange={(v) => up({ tax_group: v })}>
608
578
  <SelectTrigger><SelectValue /></SelectTrigger>
609
579
  <SelectContent>
610
- {(taxClasses.length ? taxClasses : [{ slug: "standard", name: "Standard" }]).map((c) => (
611
- <SelectItem key={c.slug} value={c.slug}>{c.name}</SelectItem>
580
+ {taxGroups.map((g) => (
581
+ <SelectItem key={g} value={g}>{g}</SelectItem>
612
582
  ))}
613
583
  </SelectContent>
614
584
  </Select>
@@ -327,7 +327,7 @@ const TOTALS_ACTIONS = [
327
327
  "coupons-totals",
328
328
  "reviews-totals",
329
329
  "categories-totals",
330
- "tags-totals",
330
+ "ribbons-totals",
331
331
  "attributes-totals",
332
332
  ];
333
333
 
@@ -381,7 +381,7 @@ function TotalsTab() {
381
381
  rows={normalizeTotals(d["reviews-totals"], REVIEW_STATUSES)}
382
382
  />
383
383
  <TotalsCard title="Categories" loading={loading} rows={normalizeTotals(d["categories-totals"])} />
384
- <TotalsCard title="Tags" loading={loading} rows={normalizeTotals(d["tags-totals"])} />
384
+ <TotalsCard title="Ribbons" loading={loading} rows={normalizeTotals(d["ribbons-totals"])} />
385
385
  <TotalsCard title="Attributes" loading={loading} rows={normalizeTotals(d["attributes-totals"])} />
386
386
  </div>
387
387
  );
@@ -63,6 +63,21 @@ export default function EmailsSettings() {
63
63
  text.split(",").map((s) => s.trim()).filter(Boolean)
64
64
  );
65
65
 
66
+ // What the Recipient column shows for an admin-targeted row: the per-type
67
+ // override, then the configured admin recipients, then the resolved admin users.
68
+ const adminRecipient = (e) =>
69
+ typeCfg(e.id).recipient ||
70
+ recipientsText ||
71
+ effective?.recipients.join(", ") ||
72
+ "Admin";
73
+
74
+ // One list for every email; a delimiter row splits the admin-targeted
75
+ // emails from the ones the customer receives.
76
+ const GROUPS = [
77
+ { label: "Sent to the store admin", types: EMAIL_TYPES.filter((e) => e.recipient === "admin") },
78
+ { label: "Sent to the customer", types: EMAIL_TYPES.filter((e) => e.recipient === "customer") },
79
+ ];
80
+
66
81
  const active = activeId ? EMAIL_TYPES.find((e) => e.id === activeId) : null;
67
82
 
68
83
  return (
@@ -117,7 +132,10 @@ export default function EmailsSettings() {
117
132
  <CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
118
133
  <div className="space-y-1.5">
119
134
  <CardTitle>Email notifications</CardTitle>
120
- <CardDescription>Enable, disable and customize each transactional email.</CardDescription>
135
+ <CardDescription>
136
+ Enable, disable and customize each transactional email, including the
137
+ low-stock and out-of-stock alerts.
138
+ </CardDescription>
121
139
  </div>
122
140
  <SaveButton />
123
141
  </CardHeader>
@@ -132,47 +150,58 @@ export default function EmailsSettings() {
132
150
  </TableRow>
133
151
  </TableHeader>
134
152
  <TableBody>
135
- {EMAIL_TYPES.map((e) => {
136
- const managed = !!e.managed_by_auth;
137
- return (
138
- <TableRow key={e.id}>
139
- <TableCell>
140
- <div className="flex items-center gap-2">
141
- <span className="font-medium">{e.label}</span>
142
- {e.manual && <Badge variant="outline" className="font-normal">Manual</Badge>}
143
- {managed && (
144
- <Badge variant="secondary" className="font-normal text-muted-foreground">
145
- Handled by Base44 auth
146
- </Badge>
147
- )}
148
- </div>
149
- </TableCell>
150
- <TableCell className="text-sm text-muted-foreground">
151
- {managed
152
- ? "—"
153
- : e.recipient === "admin"
154
- ? typeCfg(e.id).recipient || recipientsText ||
155
- effective?.recipients.join(", ") || "Admin"
156
- : "Customer"}
157
- </TableCell>
158
- <TableCell>
159
- {managed ? (
160
- <span className="text-xs text-muted-foreground">—</span>
161
- ) : (
162
- <Switch checked={isEnabled(e.id)} onCheckedChange={(v) => setTypeField(e.id, "enabled", !!v)} />
163
- )}
164
- </TableCell>
165
- <TableCell>
166
- {!managed && (
167
- <Button variant="ghost" size="sm" onClick={() => setActiveId(e.id)}>
168
- <Settings2 className="mr-2 h-4 w-4" />
169
- Manage
170
- </Button>
171
- )}
153
+ {GROUPS.map((group) => (
154
+ <React.Fragment key={group.label}>
155
+ <TableRow className="hover:bg-transparent">
156
+ <TableCell
157
+ colSpan={4}
158
+ className="bg-muted/50 py-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground"
159
+ >
160
+ {group.label}
172
161
  </TableCell>
173
162
  </TableRow>
174
- );
175
- })}
163
+ {group.types.map((e) => {
164
+ const managed = !!e.managed_by_auth;
165
+ return (
166
+ <TableRow key={e.id}>
167
+ <TableCell>
168
+ <div className="flex items-center gap-2">
169
+ <span className="font-medium">{e.label}</span>
170
+ {e.manual && <Badge variant="outline" className="font-normal">Manual</Badge>}
171
+ {managed && (
172
+ <Badge variant="secondary" className="font-normal text-muted-foreground">
173
+ Handled by Base44 auth
174
+ </Badge>
175
+ )}
176
+ </div>
177
+ </TableCell>
178
+ <TableCell className="text-sm text-muted-foreground">
179
+ {managed
180
+ ? "—"
181
+ : e.recipient === "admin"
182
+ ? adminRecipient(e)
183
+ : "Customer"}
184
+ </TableCell>
185
+ <TableCell>
186
+ {managed ? (
187
+ <span className="text-xs text-muted-foreground">—</span>
188
+ ) : (
189
+ <Switch checked={isEnabled(e.id)} onCheckedChange={(v) => setTypeField(e.id, "enabled", !!v)} />
190
+ )}
191
+ </TableCell>
192
+ <TableCell>
193
+ {!managed && (
194
+ <Button variant="ghost" size="sm" onClick={() => setActiveId(e.id)}>
195
+ <Settings2 className="mr-2 h-4 w-4" />
196
+ Manage
197
+ </Button>
198
+ )}
199
+ </TableCell>
200
+ </TableRow>
201
+ );
202
+ })}
203
+ </React.Fragment>
204
+ ))}
176
205
  </TableBody>
177
206
  </Table>
178
207
  </CardContent>
@@ -198,34 +227,38 @@ export default function EmailsSettings() {
198
227
  </p>
199
228
  </div>
200
229
  )}
201
- <div className="space-y-1.5">
202
- <Label>Subject</Label>
203
- <Input
204
- placeholder="Leave blank for the default"
205
- value={typeCfg(active.id).subject || ""}
206
- onChange={(e) => setTypeField(active.id, "subject", e.target.value)}
207
- />
208
- </div>
209
- <div className="space-y-1.5">
210
- <Label>Email heading</Label>
211
- <Input
212
- placeholder="Leave blank for the default"
213
- value={typeCfg(active.id).heading || ""}
214
- onChange={(e) => setTypeField(active.id, "heading", e.target.value)}
215
- />
216
- </div>
217
- <div className="space-y-1.5">
218
- <Label>Additional content</Label>
219
- <Textarea
220
- rows={3}
221
- placeholder="Text appended below the email body."
222
- value={typeCfg(active.id).additional_content || ""}
223
- onChange={(e) => setTypeField(active.id, "additional_content", e.target.value)}
224
- />
225
- </div>
226
- <p className="text-xs text-muted-foreground">
227
- Placeholders: {"{order_number}"}, {"{customer_name}"}, {"{store_name}"}, {"{order_total}"}.
228
- </p>
230
+ {!active.stock && (
231
+ <>
232
+ <div className="space-y-1.5">
233
+ <Label>Subject</Label>
234
+ <Input
235
+ placeholder="Leave blank for the default"
236
+ value={typeCfg(active.id).subject || ""}
237
+ onChange={(e) => setTypeField(active.id, "subject", e.target.value)}
238
+ />
239
+ </div>
240
+ <div className="space-y-1.5">
241
+ <Label>Email heading</Label>
242
+ <Input
243
+ placeholder="Leave blank for the default"
244
+ value={typeCfg(active.id).heading || ""}
245
+ onChange={(e) => setTypeField(active.id, "heading", e.target.value)}
246
+ />
247
+ </div>
248
+ <div className="space-y-1.5">
249
+ <Label>Additional content</Label>
250
+ <Textarea
251
+ rows={3}
252
+ placeholder="Text appended below the email body."
253
+ value={typeCfg(active.id).additional_content || ""}
254
+ onChange={(e) => setTypeField(active.id, "additional_content", e.target.value)}
255
+ />
256
+ </div>
257
+ <p className="text-xs text-muted-foreground">
258
+ Placeholders: {"{order_number}"}, {"{customer_name}"}, {"{store_name}"}, {"{order_total}"}.
259
+ </p>
260
+ </>
261
+ )}
229
262
  </div>
230
263
  <DialogFooter>
231
264
  <Button onClick={() => setActiveId(null)}>Done</Button>