@base44/app-plugin-commerce 0.7.1 → 0.8.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.
Files changed (81) hide show
  1. package/README.md +4 -10
  2. package/base44/entities/commerce.ProductReview.jsonc +2 -2
  3. package/base44/functions/commerce/storefront-catalog/entry.ts +7 -3
  4. package/package.json +1 -1
  5. package/scripts/install.js +2 -4
  6. package/skills/commerce/SKILL.md +9 -3
  7. package/skills/commerce/docs/api-storefront.md +3 -3
  8. package/skills/commerce/install/01-install.md +8 -24
  9. package/skills/commerce/install/02-storefront.md +21 -9
  10. package/skills/commerce/references/admin-localization.md +95 -0
  11. package/skills/commerce/references/reviews.md +7 -6
  12. package/src/commerce/admin/README.md +36 -14
  13. package/src/commerce/admin/bot/StoreAdminBot.jsx +12 -11
  14. package/src/commerce/admin/components/AddressForm.jsx +13 -12
  15. package/src/commerce/admin/components/ConfirmDialog.jsx +5 -4
  16. package/src/commerce/admin/components/CountrySelect.jsx +12 -8
  17. package/src/commerce/admin/components/DataTable.jsx +3 -2
  18. package/src/commerce/admin/components/DateRangePicker.jsx +11 -10
  19. package/src/commerce/admin/components/EmptyState.jsx +2 -1
  20. package/src/commerce/admin/components/MediaUploader.jsx +3 -2
  21. package/src/commerce/admin/components/MetaDataEditor.jsx +4 -3
  22. package/src/commerce/admin/components/SearchSelect.jsx +4 -3
  23. package/src/commerce/admin/context/SettingsContext.jsx +12 -25
  24. package/src/commerce/admin/hooks/useMoney.js +3 -2
  25. package/src/commerce/admin/i18n/index.js +54 -0
  26. package/src/commerce/admin/i18n/locales/de.js +896 -0
  27. package/src/commerce/admin/i18n/locales/en.js +900 -0
  28. package/src/commerce/admin/i18n/locales/es.js +896 -0
  29. package/src/commerce/admin/i18n/locales/fr.js +896 -0
  30. package/src/commerce/admin/i18n/locales/ja.js +896 -0
  31. package/src/commerce/admin/i18n/locales/pt.js +896 -0
  32. package/src/commerce/admin/index.jsx +6 -30
  33. package/src/commerce/admin/layout/AccessDenied.jsx +8 -9
  34. package/src/commerce/admin/layout/AuthGuard.jsx +2 -1
  35. package/src/commerce/admin/layout/Sidebar.jsx +29 -18
  36. package/src/commerce/admin/layout/Topbar.jsx +4 -3
  37. package/src/commerce/admin/lib/api.js +3 -2
  38. package/src/commerce/admin/lib/constants.js +44 -43
  39. package/src/commerce/admin/lib/format.js +4 -3
  40. package/src/commerce/admin/lib/paths.js +2 -3
  41. package/src/commerce/admin/pages/Dashboard.jsx +25 -24
  42. package/src/commerce/admin/pages/coupons/CouponEditor.jsx +55 -57
  43. package/src/commerce/admin/pages/coupons/CouponsList.jsx +22 -21
  44. package/src/commerce/admin/pages/customers/CustomerEditor.jsx +31 -30
  45. package/src/commerce/admin/pages/customers/CustomersList.jsx +19 -18
  46. package/src/commerce/admin/pages/orders/OrderEditor.jsx +75 -71
  47. package/src/commerce/admin/pages/orders/OrdersList.jsx +16 -15
  48. package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +12 -11
  49. package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +14 -13
  50. package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +12 -11
  51. package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +11 -10
  52. package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +22 -22
  53. package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +18 -17
  54. package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +18 -17
  55. package/src/commerce/admin/pages/products/Categories.jsx +14 -13
  56. package/src/commerce/admin/pages/products/ProductEditor.jsx +16 -15
  57. package/src/commerce/admin/pages/products/ProductsList.jsx +41 -36
  58. package/src/commerce/admin/pages/products/Reviews.jsx +54 -46
  59. package/src/commerce/admin/pages/products/components/AttributesSection.jsx +45 -39
  60. package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +8 -7
  61. package/src/commerce/admin/pages/products/components/PublishBox.jsx +10 -9
  62. package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +18 -17
  63. package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +11 -10
  64. package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +9 -10
  65. package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +3 -9
  66. package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +76 -78
  67. package/src/commerce/admin/pages/reports/Reports.jsx +33 -32
  68. package/src/commerce/admin/pages/settings/EmailsSettings.jsx +36 -39
  69. package/src/commerce/admin/pages/settings/GeneralSettings.jsx +13 -14
  70. package/src/commerce/admin/pages/settings/InventorySettings.jsx +12 -13
  71. package/src/commerce/admin/pages/settings/LocationEditor.jsx +48 -47
  72. package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +32 -33
  73. package/src/commerce/admin/pages/settings/SettingsLayout.jsx +13 -12
  74. package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +31 -31
  75. package/src/commerce/admin/pages/settings/useGroupForm.jsx +4 -3
  76. package/src/commerce/admin/pages/status/WebhookEditor.jsx +34 -31
  77. package/src/commerce/admin/pages/status/Webhooks.jsx +11 -10
  78. package/src/commerce/admin/routes.jsx +31 -43
  79. package/src/commerce/storefront/pickers.jsx +19 -5
  80. package/src/commerce/storefront/useCheckout.jsx +28 -3
  81. package/src/commerce/utils/storefront.js +49 -2
@@ -22,6 +22,7 @@ import { Check, MessageSquare, Pencil, ShieldAlert, Star, Trash, Trash2 } from "
22
22
  import { toast } from "sonner";
23
23
 
24
24
  import { base44, call } from "../../lib/api";
25
+ import { t } from "../../i18n";
25
26
  import { useSettings } from "../../context/SettingsContext";
26
27
  import usePagedList from "../../hooks/usePagedList";
27
28
  import PageHeader from "../../components/PageHeader";
@@ -32,11 +33,11 @@ import { REVIEW_STATUSES } from "../../lib/constants";
32
33
  import { formatDate } from "../../lib/format";
33
34
 
34
35
  const TABS = [
35
- { value: "all", label: "All" },
36
- { value: "approved", label: "Approved" },
37
- { value: "hold", label: "Pending" },
38
- { value: "spam", label: "Spam" },
39
- { value: "trash", label: "Trash" },
36
+ { value: "all", label: t("reviews.tab_all") },
37
+ { value: "approved", label: t("status.review.approved") },
38
+ { value: "hold", label: t("status.review.hold") },
39
+ { value: "spam", label: t("status.review.spam") },
40
+ { value: "trash", label: t("status.review.trash") },
40
41
  ];
41
42
 
42
43
  function Stars({ rating = 0 }) {
@@ -78,8 +79,8 @@ export default function Reviews() {
78
79
  Promise.all(
79
80
  ids.map((id) =>
80
81
  base44.entities["commerce.Product"].get(id)
81
- .then((p) => [id, p?.name || "(deleted product)"])
82
- .catch(() => [id, "(deleted product)"])
82
+ .then((p) => [id, p?.name || t("reviews.deleted_product")])
83
+ .catch(() => [id, t("reviews.deleted_product")])
83
84
  )
84
85
  ).then((pairs) => {
85
86
  if (!cancelled) setProductNames((prev) => ({ ...prev, ...Object.fromEntries(pairs) }));
@@ -91,14 +92,14 @@ export default function Reviews() {
91
92
 
92
93
  const setStatus = async (ids, status) => {
93
94
  for (const id of ids) await call("admin-reviews", "set-status", { id, status });
94
- toast.success(`Review${ids.length > 1 ? "s" : ""} updated`);
95
+ toast.success(ids.length > 1 ? t("reviews.updated_plural") : t("reviews.updated"));
95
96
  setSelected([]);
96
97
  list.refetch();
97
98
  };
98
99
 
99
100
  const saveEdit = async () => {
100
101
  await call("admin-reviews", "update", { id: editing.id, rating: editing.rating, review: editing.review });
101
- toast.success("Review updated");
102
+ toast.success(t("reviews.updated"));
102
103
  setEditing(null);
103
104
  list.refetch();
104
105
  };
@@ -108,7 +109,7 @@ export default function Reviews() {
108
109
  try {
109
110
  const ids = confirmDelete === "bulk" ? selected : [confirmDelete.id];
110
111
  for (const id of ids) await call("admin-reviews", "delete", { id });
111
- toast.success(`Review${ids.length > 1 ? "s" : ""} deleted`);
112
+ toast.success(ids.length > 1 ? t("reviews.deleted_plural") : t("reviews.deleted"));
112
113
  setSelected([]);
113
114
  setConfirmDelete(null);
114
115
  list.refetch();
@@ -123,58 +124,61 @@ export default function Reviews() {
123
124
  () => [
124
125
  {
125
126
  key: "reviewer",
126
- label: "Author",
127
+ label: t("reviews.author"),
127
128
  render: (row) => (
128
129
  <div>
129
- <div className="font-medium">{row.reviewer || "Anonymous"}</div>
130
+ <div className="font-medium">{row.reviewer || t("reviews.anonymous")}</div>
130
131
  {row.reviewer_email && <div className="text-xs text-muted-foreground">{row.reviewer_email}</div>}
131
132
  </div>
132
133
  ),
133
134
  },
134
- { key: "rating", label: "Rating", render: (row) => <Stars rating={row.rating || 0} /> },
135
+ { key: "rating", label: t("reviews.rating"), render: (row) => <Stars rating={row.rating || 0} /> },
135
136
  {
136
137
  key: "review",
137
- label: "Review",
138
+ label: t("reviews.review"),
138
139
  render: (row) => (
139
140
  <div className="max-w-md">
140
- <p className="line-clamp-2 text-sm">{row.review}</p>
141
- {row.verified && <span className="text-xs text-green-700">✓ Verified owner</span>}
141
+ {/* A rating-only review is valid — say so, or the row reads as blank. */}
142
+ {row.review
143
+ ? <p className="line-clamp-2 text-sm">{row.review}</p>
144
+ : <p className="text-sm italic text-muted-foreground">{t("reviews.rating_only")}</p>}
145
+ {row.verified && <span className="text-xs text-green-700">{t("reviews.verified_owner")}</span>}
142
146
  </div>
143
147
  ),
144
148
  },
145
149
  {
146
150
  key: "product_id",
147
- label: "Product",
151
+ label: t("reviews.product"),
148
152
  className: "hidden md:table-cell",
149
153
  render: (row) => productNames[row.product_id] || "…",
150
154
  },
151
155
  {
152
156
  key: "created_date",
153
- label: "Date",
157
+ label: t("reviews.date"),
154
158
  className: "hidden lg:table-cell",
155
159
  render: (row) => formatDate(row.created_date),
156
160
  },
157
- { key: "status", label: "Status", render: (row) => <StatusBadge status={row.status} map={REVIEW_STATUSES} /> },
161
+ { key: "status", label: t("common.status"), render: (row) => <StatusBadge status={row.status} map={REVIEW_STATUSES} /> },
158
162
  ],
159
163
  [productNames]
160
164
  );
161
165
 
162
166
  const rowActions = (row) => {
163
167
  const actions = [];
164
- if (row.status !== "approved") actions.push({ label: "Approve", icon: Check, onClick: () => setStatus([row.id], "approved") });
165
- if (row.status !== "hold") actions.push({ label: "Hold", icon: MessageSquare, onClick: () => setStatus([row.id], "hold") });
166
- if (row.status !== "spam") actions.push({ label: "Spam", icon: ShieldAlert, onClick: () => setStatus([row.id], "spam") });
167
- if (row.status !== "trash") actions.push({ label: "Trash", icon: Trash, onClick: () => setStatus([row.id], "trash") });
168
- actions.push({ label: "Edit", icon: Pencil, onClick: () => setEditing({ id: row.id, rating: row.rating || 5, review: row.review || "" }) });
169
- actions.push({ label: "Delete permanently", icon: Trash2, destructive: true, onClick: () => setConfirmDelete(row) });
168
+ if (row.status !== "approved") actions.push({ label: t("reviews.approve"), icon: Check, onClick: () => setStatus([row.id], "approved") });
169
+ if (row.status !== "hold") actions.push({ label: t("reviews.hold"), icon: MessageSquare, onClick: () => setStatus([row.id], "hold") });
170
+ if (row.status !== "spam") actions.push({ label: t("status.review.spam"), icon: ShieldAlert, onClick: () => setStatus([row.id], "spam") });
171
+ if (row.status !== "trash") actions.push({ label: t("status.review.trash"), icon: Trash, onClick: () => setStatus([row.id], "trash") });
172
+ actions.push({ label: t("common.edit"), icon: Pencil, onClick: () => setEditing({ id: row.id, rating: row.rating || 5, review: row.review || "" }) });
173
+ actions.push({ label: t("reviews.delete_permanently"), icon: Trash2, destructive: true, onClick: () => setConfirmDelete(row) });
170
174
  return actions;
171
175
  };
172
176
 
173
177
  return (
174
178
  <div className="space-y-4">
175
179
  <PageHeader
176
- title="Reviews"
177
- description="Moderate customer product reviews"
180
+ title={t("routes.reviews")}
181
+ description={t("reviews.page_description")}
178
182
  actions={<AutoApproveToggle />}
179
183
  />
180
184
 
@@ -188,12 +192,12 @@ export default function Reviews() {
188
192
 
189
193
  {selected.length > 0 && (
190
194
  <div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 px-3 py-2">
191
- <span className="text-sm text-muted-foreground">{selected.length} selected</span>
192
- <Button size="sm" variant="outline" onClick={() => setStatus(selected, "approved")}>Approve</Button>
193
- <Button size="sm" variant="outline" onClick={() => setStatus(selected, "hold")}>Hold</Button>
194
- <Button size="sm" variant="outline" onClick={() => setStatus(selected, "spam")}>Spam</Button>
195
- <Button size="sm" variant="outline" onClick={() => setStatus(selected, "trash")}>Trash</Button>
196
- <Button size="sm" variant="destructive" onClick={() => setConfirmDelete("bulk")}>Delete</Button>
195
+ <span className="text-sm text-muted-foreground">{t("common.selected_count", { count: selected.length })}</span>
196
+ <Button size="sm" variant="outline" onClick={() => setStatus(selected, "approved")}>{t("reviews.approve")}</Button>
197
+ <Button size="sm" variant="outline" onClick={() => setStatus(selected, "hold")}>{t("reviews.hold")}</Button>
198
+ <Button size="sm" variant="outline" onClick={() => setStatus(selected, "spam")}>{t("status.review.spam")}</Button>
199
+ <Button size="sm" variant="outline" onClick={() => setStatus(selected, "trash")}>{t("status.review.trash")}</Button>
200
+ <Button size="sm" variant="destructive" onClick={() => setConfirmDelete("bulk")}>{t("common.delete")}</Button>
197
201
  </div>
198
202
  )}
199
203
 
@@ -206,18 +210,18 @@ export default function Reviews() {
206
210
  onSelectChange={setSelected}
207
211
  pagination={{ page: list.page, hasNext: list.hasNext, onNext: list.next, onPrev: list.prev }}
208
212
  rowActions={rowActions}
209
- empty={{ icon: MessageSquare, title: "No reviews", description: "Reviews submitted by customers appear here." }}
213
+ empty={{ icon: MessageSquare, title: t("reviews.empty_title"), description: t("reviews.empty_description") }}
210
214
  />
211
215
 
212
216
  <Dialog open={Boolean(editing)} onOpenChange={(o) => !o && setEditing(null)}>
213
217
  <DialogContent>
214
218
  <DialogHeader>
215
- <DialogTitle>Edit review</DialogTitle>
219
+ <DialogTitle>{t("reviews.edit_review")}</DialogTitle>
216
220
  </DialogHeader>
217
221
  {editing && (
218
222
  <div className="space-y-3">
219
223
  <div className="space-y-1.5">
220
- <Label>Rating</Label>
224
+ <Label>{t("reviews.rating")}</Label>
221
225
  <Select
222
226
  value={String(editing.rating)}
223
227
  onValueChange={(v) => setEditing((e) => ({ ...e, rating: Number(v) }))}
@@ -225,13 +229,13 @@ export default function Reviews() {
225
229
  <SelectTrigger className="w-32"><SelectValue /></SelectTrigger>
226
230
  <SelectContent>
227
231
  {[1, 2, 3, 4, 5].map((n) => (
228
- <SelectItem key={n} value={String(n)}>{n} star{n > 1 ? "s" : ""}</SelectItem>
232
+ <SelectItem key={n} value={String(n)}>{t(n > 1 ? "reviews.n_stars" : "reviews.n_star", { count: n })}</SelectItem>
229
233
  ))}
230
234
  </SelectContent>
231
235
  </Select>
232
236
  </div>
233
237
  <div className="space-y-1.5">
234
- <Label>Review</Label>
238
+ <Label>{t("reviews.review")}</Label>
235
239
  <Textarea
236
240
  rows={5}
237
241
  value={editing.review}
@@ -241,8 +245,8 @@ export default function Reviews() {
241
245
  </div>
242
246
  )}
243
247
  <DialogFooter>
244
- <Button variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
245
- <Button onClick={saveEdit}>Save</Button>
248
+ <Button variant="ghost" onClick={() => setEditing(null)}>{t("common.cancel")}</Button>
249
+ <Button onClick={saveEdit}>{t("common.save")}</Button>
246
250
  </DialogFooter>
247
251
  </DialogContent>
248
252
  </Dialog>
@@ -250,9 +254,13 @@ export default function Reviews() {
250
254
  <ConfirmDialog
251
255
  open={Boolean(confirmDelete)}
252
256
  onOpenChange={(o) => !o && setConfirmDelete(null)}
253
- title={confirmDelete === "bulk" ? `Delete ${selected.length} review(s)?` : "Delete this review?"}
254
- description="This permanently removes the review."
255
- confirmLabel="Delete"
257
+ title={
258
+ confirmDelete === "bulk"
259
+ ? t("reviews.confirm_delete_bulk", { count: selected.length })
260
+ : t("reviews.confirm_delete_one")
261
+ }
262
+ description={t("reviews.confirm_delete_description")}
263
+ confirmLabel={t("common.delete")}
256
264
  onConfirm={doDelete}
257
265
  loading={deleting}
258
266
  />
@@ -274,9 +282,9 @@ function AutoApproveToggle() {
274
282
  setSaving(true);
275
283
  try {
276
284
  await settings.saveGroup("products", { ...(settings.settings?.products || {}), auto_approve_reviews: v });
277
- toast.success(v ? "New reviews publish immediately" : "New reviews are held for moderation");
285
+ toast.success(v ? t("reviews.auto_approve_on") : t("reviews.auto_approve_off"));
278
286
  } catch (e) {
279
- toast.error(e.message || "Failed to save");
287
+ toast.error(e.message || t("reviews.save_failed"));
280
288
  } finally {
281
289
  setSaving(false);
282
290
  }
@@ -284,7 +292,7 @@ function AutoApproveToggle() {
284
292
 
285
293
  return (
286
294
  <label className="flex items-center gap-2 text-sm">
287
- <span className="text-muted-foreground">Auto-approve new reviews</span>
295
+ <span className="text-muted-foreground">{t("reviews.auto_approve_label")}</span>
288
296
  <Switch checked={enabled} disabled={saving} onCheckedChange={toggle} />
289
297
  </label>
290
298
  );
@@ -13,13 +13,13 @@ import {
13
13
  import { ChevronDown, ChevronRight, ChevronUp, Loader2, Plus, Settings2, Trash2 } from "lucide-react";
14
14
 
15
15
  import { base44, call } from "../../../lib/api";
16
+ import { t } from "../../../i18n";
16
17
  import SearchSelect from "../../../components/SearchSelect";
17
18
  import ConfirmDialog from "../../../components/ConfirmDialog";
18
19
  import { slugify } from "../../../lib/product-utils";
19
20
 
20
- /** Sentinel option value for "create the thing I just typed". */
21
+ /** Sentinel option value for "create the thing I just typed"; the typed name rides on `name`. */
21
22
  const CREATE = "__create__";
22
- const createdName = (label) => label.replace(/^Create "/, "").replace(/"$/, "");
23
23
 
24
24
  const fetchValues = (attributeId) =>
25
25
  base44.entities["commerce.ProductAttributeTerm"]
@@ -141,16 +141,16 @@ export default function AttributesSection({ product, up, refs, variations = [] }
141
141
  <div className="space-y-3">
142
142
  <div className="flex items-start justify-between gap-4">
143
143
  <div>
144
- <h3 className="text-sm font-semibold">Options</h3>
144
+ <h3 className="text-sm font-semibold">{t("products.options")}</h3>
145
145
  <p className="text-xs text-muted-foreground">
146
- What this product comes in — each combination of values becomes a variant below.
146
+ {t("products.options_hint")}
147
147
  </p>
148
148
  </div>
149
149
  <Button
150
150
  type="button"
151
151
  variant="ghost"
152
152
  size="icon"
153
- title="Manage option catalog (affects all products)"
153
+ title={t("products.manage_option_catalog")}
154
154
  onClick={() => setManageOpen(true)}
155
155
  >
156
156
  <Settings2 className="h-4 w-4 text-muted-foreground" />
@@ -159,7 +159,7 @@ export default function AttributesSection({ product, up, refs, variations = [] }
159
159
 
160
160
  {attributes.length === 0 && (
161
161
  <p className="text-sm text-muted-foreground">
162
- No options yet. Add one (Size, Color, Pack…) and pick the values this product comes in.
162
+ {t("products.options_empty")}
163
163
  </p>
164
164
  )}
165
165
 
@@ -175,7 +175,7 @@ export default function AttributesSection({ product, up, refs, variations = [] }
175
175
  className="text-muted-foreground disabled:opacity-30"
176
176
  disabled={i === 0}
177
177
  onClick={() => moveRow(i, -1)}
178
- title="Move up"
178
+ title={t("products.move_up")}
179
179
  >
180
180
  <ChevronUp className="h-4 w-4" />
181
181
  </button>
@@ -184,7 +184,7 @@ export default function AttributesSection({ product, up, refs, variations = [] }
184
184
  className="text-muted-foreground disabled:opacity-30"
185
185
  disabled={i === attributes.length - 1}
186
186
  onClick={() => moveRow(i, 1)}
187
- title="Move down"
187
+ title={t("products.move_down")}
188
188
  >
189
189
  <ChevronDown className="h-4 w-4" />
190
190
  </button>
@@ -199,19 +199,19 @@ export default function AttributesSection({ product, up, refs, variations = [] }
199
199
  ? setPendingRemoveRow(i)
200
200
  : removeRow(i)
201
201
  }
202
- title="Remove from this product"
202
+ title={t("products.remove_from_product")}
203
203
  >
204
204
  <Trash2 className="h-4 w-4 text-muted-foreground" />
205
205
  </Button>
206
206
  </div>
207
207
  <SearchSelect
208
208
  multiple
209
- placeholder="Values this product comes in…"
209
+ placeholder={t("products.values_placeholder")}
210
210
  value={selected.map((o) => ({ value: o, label: o }))}
211
211
  onChange={(next) => {
212
212
  const created = next.find((o) => o.value === CREATE);
213
213
  if (created) {
214
- createAndSelectValue(row, i, createdName(created.label));
214
+ createAndSelectValue(row, i, created.name);
215
215
  return;
216
216
  }
217
217
  const options = next.map((o) => o.value);
@@ -229,7 +229,7 @@ export default function AttributesSection({ product, up, refs, variations = [] }
229
229
  .filter((v) => !term || v.name.toLowerCase().includes(term.toLowerCase()))
230
230
  .map((v) => ({ value: v.name, label: v.name }));
231
231
  const exact = values.some((v) => v.name.toLowerCase() === term.toLowerCase());
232
- if (term && !exact) opts.push({ value: CREATE, label: `Create "${term}"` });
232
+ if (term && !exact) opts.push({ value: CREATE, label: t("products.create_named", { name: term }), name: term });
233
233
  return opts;
234
234
  }}
235
235
  />
@@ -240,11 +240,11 @@ export default function AttributesSection({ product, up, refs, variations = [] }
240
240
  <div className="flex items-center gap-2">
241
241
  <SearchSelect
242
242
  className="w-72"
243
- placeholder="Add option…"
243
+ placeholder={t("products.add_option_placeholder")}
244
244
  value={null}
245
245
  onChange={(opt) => {
246
246
  if (!opt) return;
247
- if (opt.value === CREATE) createAndAttach(createdName(opt.label));
247
+ if (opt.value === CREATE) createAndAttach(opt.name);
248
248
  else attach(globalAttributes.find((a) => a.id === opt.value));
249
249
  }}
250
250
  search={async (q) => {
@@ -254,7 +254,7 @@ export default function AttributesSection({ product, up, refs, variations = [] }
254
254
  .filter((a) => !term || a.name.toLowerCase().includes(term.toLowerCase()))
255
255
  .map((a) => ({ value: a.id, label: a.name, meta: a.code || undefined }));
256
256
  const exact = globalAttributes.some((a) => a.name.toLowerCase() === term.toLowerCase());
257
- if (term && !exact) opts.push({ value: CREATE, label: `Create "${term}"` });
257
+ if (term && !exact) opts.push({ value: CREATE, label: t("products.create_named", { name: term }), name: term });
258
258
  return opts;
259
259
  }}
260
260
  />
@@ -275,11 +275,18 @@ export default function AttributesSection({ product, up, refs, variations = [] }
275
275
  <ConfirmDialog
276
276
  open
277
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"
278
+ title={t("products.confirm_remove_values", {
279
+ values: pendingValues.removed.map((r) => `“${r}”`).join(", "),
280
+ })}
281
+ description={
282
+ pendingValues.stock
283
+ ? t("products.confirm_remove_values_description_stock", {
284
+ count: pendingValues.affected.length,
285
+ stock: pendingValues.stock,
286
+ })
287
+ : t("products.confirm_remove_values_description", { count: pendingValues.affected.length })
288
+ }
289
+ confirmLabel={t("products.delete_variants")}
283
290
  onConfirm={() => {
284
291
  setRow(pendingValues.index, { options: pendingValues.options });
285
292
  setPendingValues(null);
@@ -291,13 +298,13 @@ export default function AttributesSection({ product, up, refs, variations = [] }
291
298
  <ConfirmDialog
292
299
  open
293
300
  onOpenChange={(o) => !o && setPendingRemoveRow(null)}
294
- title={`Remove the “${attributes[pendingRemoveRow]?.name}” option?`}
301
+ title={t("products.confirm_remove_option", { name: attributes[pendingRemoveRow]?.name })}
295
302
  description={
296
303
  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.`
304
+ ? t("products.confirm_remove_option_all", { count: variations.length })
305
+ : t("products.confirm_remove_option_rebuild", { count: variations.length })
299
306
  }
300
- confirmLabel="Remove option"
307
+ confirmLabel={t("products.remove_option")}
301
308
  onConfirm={() => {
302
309
  removeRow(pendingRemoveRow);
303
310
  setPendingRemoveRow(null);
@@ -378,7 +385,7 @@ function ManageAttributesDialog({ attributes, valuesByAttr, loadValues, onClose,
378
385
  try {
379
386
  await call("admin-products", "save-term", {
380
387
  taxonomy: "attribute-term",
381
- term: { attribute_id: attributeId, name: `Value ${order + 1}`, order },
388
+ term: { attribute_id: attributeId, name: t("products.default_value_name", { number: order + 1 }), order },
382
389
  });
383
390
  await loadValues(attributeId);
384
391
  } catch {
@@ -398,18 +405,17 @@ function ManageAttributesDialog({ attributes, valuesByAttr, loadValues, onClose,
398
405
  <Dialog open onOpenChange={(o) => !o && onClose()}>
399
406
  <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
400
407
  <DialogHeader>
401
- <DialogTitle>Option catalog</DialogTitle>
408
+ <DialogTitle>{t("products.option_catalog")}</DialogTitle>
402
409
  </DialogHeader>
403
410
  <p className="-mt-2 text-xs text-muted-foreground">
404
- Shared across every product — changes save immediately. To delete an option or a value,
405
- remove it from the entity data.
411
+ {t("products.option_catalog_hint")}
406
412
  </p>
407
413
 
408
414
  <div className="space-y-2">
409
415
  <div className="grid grid-cols-[1fr_140px_72px_32px] gap-2 text-xs text-muted-foreground">
410
- <span>Name</span>
411
- <span>Code</span>
412
- <span>Order</span>
416
+ <span>{t("common.name")}</span>
417
+ <span>{t("products.code")}</span>
418
+ <span>{t("products.order")}</span>
413
419
  <span />
414
420
  </div>
415
421
  {attributes.map((attr) => (
@@ -431,7 +437,7 @@ function ManageAttributesDialog({ attributes, valuesByAttr, loadValues, onClose,
431
437
  className="h-8"
432
438
  onBlur={(e) => saveAttribute(attr, { order: Number(e.target.value) || 0 })}
433
439
  />
434
- <button type="button" className="text-muted-foreground" onClick={() => toggle(attr.id)} title="Values">
440
+ <button type="button" className="text-muted-foreground" onClick={() => toggle(attr.id)} title={t("products.values")}>
435
441
  {expanded === attr.id ? (
436
442
  <ChevronDown className="h-4 w-4" />
437
443
  ) : (
@@ -458,36 +464,36 @@ function ManageAttributesDialog({ attributes, valuesByAttr, loadValues, onClose,
458
464
  </div>
459
465
  ))}
460
466
  {!(valuesByAttr[attr.id] || []).length && (
461
- <p className="text-xs text-muted-foreground">No values yet.</p>
467
+ <p className="text-xs text-muted-foreground">{t("products.no_values")}</p>
462
468
  )}
463
469
  <Button type="button" variant="outline" size="sm" onClick={() => addValue(attr.id)}>
464
- <Plus className="mr-1 h-4 w-4" /> Add value
470
+ <Plus className="mr-1 h-4 w-4" /> {t("products.add_value")}
465
471
  </Button>
466
472
  </div>
467
473
  )}
468
474
  </div>
469
475
  ))}
470
- {!attributes.length && <p className="text-sm text-muted-foreground">No options yet.</p>}
476
+ {!attributes.length && <p className="text-sm text-muted-foreground">{t("products.no_options")}</p>}
471
477
  </div>
472
478
 
473
479
  <div className="flex items-end gap-2 border-t pt-3">
474
480
  <div className="flex-1 space-y-1.5">
475
- <Label>New option</Label>
481
+ <Label>{t("products.new_option")}</Label>
476
482
  <Input
477
- placeholder="e.g. Fabric"
483
+ placeholder={t("products.new_option_placeholder")}
478
484
  value={newName}
479
485
  onChange={(e) => setNewName(e.target.value)}
480
486
  onKeyDown={(e) => e.key === "Enter" && addAttribute()}
481
487
  />
482
488
  </div>
483
489
  <Button type="button" variant="outline" onClick={addAttribute} disabled={!newName.trim() || saving}>
484
- <Plus className="mr-1 h-4 w-4" /> Add
490
+ <Plus className="mr-1 h-4 w-4" /> {t("common.add")}
485
491
  </Button>
486
492
  </div>
487
493
 
488
494
  <DialogFooter>
489
495
  {saving && <Loader2 className="mr-auto h-4 w-4 animate-spin text-muted-foreground" />}
490
- <Button onClick={onClose}>Done</Button>
496
+ <Button onClick={onClose}>{t("products.done")}</Button>
491
497
  </DialogFooter>
492
498
  </DialogContent>
493
499
  </Dialog>
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import { Card, CardContent, CardHeader } from "@/components/ui/card";
3
3
  import { Checkbox } from "@/components/ui/checkbox";
4
4
 
5
+ import { t } from "../../../i18n";
5
6
  import PriceInventoryTab from "./tabs/PriceInventoryTab";
6
7
  import ModifiersTab from "./tabs/ModifiersTab";
7
8
  import LinkedTab from "./tabs/LinkedTab";
@@ -18,7 +19,7 @@ export default function ProductDataPanel({ product, up, refs, variations, setVar
18
19
  const sections = [
19
20
  {
20
21
  id: "price",
21
- label: "Price & Inventory",
22
+ label: t("products.price_inventory"),
22
23
  body: (
23
24
  <PriceInventoryTab
24
25
  product={product}
@@ -29,31 +30,31 @@ export default function ProductDataPanel({ product, up, refs, variations, setVar
29
30
  />
30
31
  ),
31
32
  },
32
- { id: "modifiers", label: "Specifications", body: <ModifiersTab product={product} up={up} /> },
33
+ { id: "modifiers", label: t("products.specifications"), body: <ModifiersTab product={product} up={up} /> },
33
34
  ...(product.downloadable
34
- ? [{ id: "downloads", label: "Downloads", body: <DownloadsTab product={product} up={up} /> }]
35
+ ? [{ id: "downloads", label: t("products.downloads"), body: <DownloadsTab product={product} up={up} /> }]
35
36
  : []),
36
- { id: "linked", label: "Linked products", body: <LinkedTab product={product} up={up} /> },
37
+ { id: "linked", label: t("products.linked_products"), body: <LinkedTab product={product} up={up} /> },
37
38
  ];
38
39
 
39
40
  return (
40
41
  <Card>
41
42
  <CardHeader className="flex-row items-center gap-4 space-y-0 border-b py-3">
42
- <span className="text-base font-semibold">Product data</span>
43
+ <span className="text-base font-semibold">{t("products.product_data")}</span>
43
44
  <div className="flex items-center gap-4 text-sm">
44
45
  <label className="flex items-center gap-1.5">
45
46
  <Checkbox
46
47
  checked={product.virtual}
47
48
  onCheckedChange={(v) => up({ virtual: Boolean(v) })}
48
49
  />
49
- Virtual
50
+ {t("products.virtual")}
50
51
  </label>
51
52
  <label className="flex items-center gap-1.5">
52
53
  <Checkbox
53
54
  checked={product.downloadable}
54
55
  onCheckedChange={(v) => up({ downloadable: Boolean(v) })}
55
56
  />
56
- Downloadable
57
+ {t("products.downloadable")}
57
58
  </label>
58
59
  </div>
59
60
  </CardHeader>
@@ -7,6 +7,7 @@ import { Switch } from "@/components/ui/switch";
7
7
  import { Loader2, Save, Trash2 } from "lucide-react";
8
8
 
9
9
  import ConfirmDialog from "../../../components/ConfirmDialog";
10
+ import { t } from "../../../i18n";
10
11
 
11
12
  /**
12
13
  * Product editor "Publish" sidebar box. One Visible toggle drives `status`
@@ -32,14 +33,14 @@ export default function PublishBox({ product, up, onSave, onDelete, saving, dirt
32
33
  return (
33
34
  <Card>
34
35
  <CardHeader className="pb-2">
35
- <CardTitle className="text-base">Publish</CardTitle>
36
+ <CardTitle className="text-base">{t("products.publish")}</CardTitle>
36
37
  </CardHeader>
37
38
  <CardContent className="space-y-3">
38
39
  <div className="flex items-start justify-between gap-4">
39
40
  <div>
40
- <Label>Visible</Label>
41
+ <Label>{t("products.visible")}</Label>
41
42
  <p className="text-xs text-muted-foreground">
42
- {visible ? "Shown in the store" : "Hidden — saved as a draft"}
43
+ {visible ? t("products.visible_on") : t("products.visible_off")}
43
44
  </p>
44
45
  </div>
45
46
  <Switch checked={visible} onCheckedChange={(v) => up({ status: v ? "publish" : "draft" })} />
@@ -50,16 +51,16 @@ export default function PublishBox({ product, up, onSave, onDelete, saving, dirt
50
51
  checked={Boolean(product.featured)}
51
52
  onCheckedChange={(c) => up({ featured: Boolean(c) })}
52
53
  />
53
- Featured product
54
+ {t("products.featured_product")}
54
55
  </label>
55
56
 
56
57
  <div className="flex items-center gap-2 pt-1">
57
58
  <Button onClick={onSave} disabled={saving || !dirty} className="flex-1">
58
59
  {saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
59
- Save
60
+ {t("common.save")}
60
61
  </Button>
61
62
  {onDelete && (
62
- <Button variant="outline" size="icon" onClick={() => setConfirm(true)} title="Delete product">
63
+ <Button variant="outline" size="icon" onClick={() => setConfirm(true)} title={t("products.delete_product")}>
63
64
  <Trash2 className="h-4 w-4 text-destructive" />
64
65
  </Button>
65
66
  )}
@@ -69,9 +70,9 @@ export default function PublishBox({ product, up, onSave, onDelete, saving, dirt
69
70
  <ConfirmDialog
70
71
  open={confirm}
71
72
  onOpenChange={(o) => !o && setConfirm(false)}
72
- title="Delete this product?"
73
- description="This permanently deletes the product and its variations."
74
- confirmLabel="Delete"
73
+ title={t("products.confirm_delete_this")}
74
+ description={t("products.confirm_delete_description")}
75
+ confirmLabel={t("common.delete")}
75
76
  onConfirm={doDelete}
76
77
  loading={deleting}
77
78
  />