@base44/app-plugin-commerce 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -10
- package/base44/entities/commerce.ProductReview.jsonc +2 -2
- package/base44/functions/commerce/storefront-catalog/entry.ts +7 -3
- package/package.json +1 -1
- package/scripts/install.js +2 -4
- package/skills/commerce/SKILL.md +9 -3
- package/skills/commerce/docs/api-storefront.md +3 -3
- package/skills/commerce/install/01-install.md +8 -24
- package/skills/commerce/install/02-storefront.md +22 -9
- package/skills/commerce/references/admin-localization.md +95 -0
- package/skills/commerce/references/catalog-rendering.md +1 -1
- package/skills/commerce/references/reviews.md +7 -6
- package/src/commerce/admin/README.md +36 -14
- package/src/commerce/admin/bot/StoreAdminBot.jsx +12 -11
- package/src/commerce/admin/components/AddressForm.jsx +13 -12
- package/src/commerce/admin/components/ConfirmDialog.jsx +5 -4
- package/src/commerce/admin/components/CountrySelect.jsx +12 -8
- package/src/commerce/admin/components/DataTable.jsx +3 -2
- package/src/commerce/admin/components/DateRangePicker.jsx +11 -10
- package/src/commerce/admin/components/EmptyState.jsx +2 -1
- package/src/commerce/admin/components/MediaUploader.jsx +3 -2
- package/src/commerce/admin/components/MetaDataEditor.jsx +4 -3
- package/src/commerce/admin/components/SearchSelect.jsx +4 -3
- package/src/commerce/admin/context/SettingsContext.jsx +12 -25
- package/src/commerce/admin/hooks/useMoney.js +3 -2
- package/src/commerce/admin/i18n/index.js +54 -0
- package/src/commerce/admin/i18n/locales/de.js +896 -0
- package/src/commerce/admin/i18n/locales/en.js +900 -0
- package/src/commerce/admin/i18n/locales/es.js +896 -0
- package/src/commerce/admin/i18n/locales/fr.js +896 -0
- package/src/commerce/admin/i18n/locales/ja.js +896 -0
- package/src/commerce/admin/i18n/locales/pt.js +896 -0
- package/src/commerce/admin/index.jsx +6 -30
- package/src/commerce/admin/layout/AccessDenied.jsx +8 -9
- package/src/commerce/admin/layout/AuthGuard.jsx +2 -1
- package/src/commerce/admin/layout/Sidebar.jsx +29 -18
- package/src/commerce/admin/layout/Topbar.jsx +4 -3
- package/src/commerce/admin/lib/api.js +3 -2
- package/src/commerce/admin/lib/constants.js +44 -43
- package/src/commerce/admin/lib/format.js +4 -3
- package/src/commerce/admin/lib/paths.js +2 -3
- package/src/commerce/admin/pages/Dashboard.jsx +25 -24
- package/src/commerce/admin/pages/coupons/CouponEditor.jsx +55 -57
- package/src/commerce/admin/pages/coupons/CouponsList.jsx +22 -21
- package/src/commerce/admin/pages/customers/CustomerEditor.jsx +31 -30
- package/src/commerce/admin/pages/customers/CustomersList.jsx +19 -18
- package/src/commerce/admin/pages/orders/OrderEditor.jsx +75 -71
- package/src/commerce/admin/pages/orders/OrdersList.jsx +16 -15
- package/src/commerce/admin/pages/orders/components/AddProductDialog.jsx +12 -11
- package/src/commerce/admin/pages/orders/components/DownloadPermissionsPanel.jsx +14 -13
- package/src/commerce/admin/pages/orders/components/LineItemsTable.jsx +12 -11
- package/src/commerce/admin/pages/orders/components/OrderNotesPanel.jsx +11 -10
- package/src/commerce/admin/pages/orders/components/PaymentPanel.jsx +22 -22
- package/src/commerce/admin/pages/orders/components/RefundPanel.jsx +18 -17
- package/src/commerce/admin/pages/orders/components/TotalsBox.jsx +18 -17
- package/src/commerce/admin/pages/products/Categories.jsx +14 -13
- package/src/commerce/admin/pages/products/ProductEditor.jsx +16 -15
- package/src/commerce/admin/pages/products/ProductsList.jsx +41 -36
- package/src/commerce/admin/pages/products/Reviews.jsx +54 -46
- package/src/commerce/admin/pages/products/components/AttributesSection.jsx +45 -39
- package/src/commerce/admin/pages/products/components/ProductDataPanel.jsx +8 -7
- package/src/commerce/admin/pages/products/components/PublishBox.jsx +10 -9
- package/src/commerce/admin/pages/products/components/TaxonomyPanel.jsx +18 -17
- package/src/commerce/admin/pages/products/components/tabs/DownloadsTab.jsx +11 -10
- package/src/commerce/admin/pages/products/components/tabs/LinkedTab.jsx +9 -10
- package/src/commerce/admin/pages/products/components/tabs/ModifiersTab.jsx +3 -9
- package/src/commerce/admin/pages/products/components/tabs/PriceInventoryTab.jsx +76 -78
- package/src/commerce/admin/pages/reports/Reports.jsx +33 -32
- package/src/commerce/admin/pages/settings/EmailsSettings.jsx +36 -39
- package/src/commerce/admin/pages/settings/GeneralSettings.jsx +13 -14
- package/src/commerce/admin/pages/settings/InventorySettings.jsx +12 -13
- package/src/commerce/admin/pages/settings/LocationEditor.jsx +48 -47
- package/src/commerce/admin/pages/settings/PaymentsSettings.jsx +32 -33
- package/src/commerce/admin/pages/settings/SettingsLayout.jsx +13 -12
- package/src/commerce/admin/pages/settings/ShippingTaxSettings.jsx +31 -31
- package/src/commerce/admin/pages/settings/useGroupForm.jsx +4 -3
- package/src/commerce/admin/pages/status/WebhookEditor.jsx +34 -31
- package/src/commerce/admin/pages/status/Webhooks.jsx +11 -10
- package/src/commerce/admin/routes.jsx +31 -43
- package/src/commerce/storefront/pickers.jsx +19 -5
- package/src/commerce/storefront/useCheckout.jsx +28 -3
- 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: "
|
|
36
|
-
{ value: "approved", label: "
|
|
37
|
-
{ value: "hold", label: "
|
|
38
|
-
{ value: "spam", label: "
|
|
39
|
-
{ value: "trash", label: "
|
|
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 || "
|
|
82
|
-
.catch(() => [id, "
|
|
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(
|
|
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("
|
|
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(
|
|
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: "
|
|
127
|
+
label: t("reviews.author"),
|
|
127
128
|
render: (row) => (
|
|
128
129
|
<div>
|
|
129
|
-
<div className="font-medium">{row.reviewer || "
|
|
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: "
|
|
135
|
+
{ key: "rating", label: t("reviews.rating"), render: (row) => <Stars rating={row.rating || 0} /> },
|
|
135
136
|
{
|
|
136
137
|
key: "review",
|
|
137
|
-
label: "
|
|
138
|
+
label: t("reviews.review"),
|
|
138
139
|
render: (row) => (
|
|
139
140
|
<div className="max-w-md">
|
|
140
|
-
|
|
141
|
-
{row.
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
165
|
-
if (row.status !== "hold") actions.push({ label: "
|
|
166
|
-
if (row.status !== "spam") actions.push({ label: "
|
|
167
|
-
if (row.status !== "trash") actions.push({ label: "
|
|
168
|
-
actions.push({ label: "
|
|
169
|
-
actions.push({ label: "
|
|
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="
|
|
177
|
-
description="
|
|
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}
|
|
192
|
-
<Button size="sm" variant="outline" onClick={() => setStatus(selected, "approved")}>
|
|
193
|
-
<Button size="sm" variant="outline" onClick={() => setStatus(selected, "hold")}>
|
|
194
|
-
<Button size="sm" variant="outline" onClick={() => setStatus(selected, "spam")}>
|
|
195
|
-
<Button size="sm" variant="outline" onClick={() => setStatus(selected, "trash")}>
|
|
196
|
-
<Button size="sm" variant="destructive" onClick={() => setConfirmDelete("bulk")}>
|
|
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: "
|
|
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>
|
|
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>
|
|
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
|
|
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>
|
|
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)}>
|
|
245
|
-
<Button onClick={saveEdit}>
|
|
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={
|
|
254
|
-
|
|
255
|
-
|
|
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 ? "
|
|
285
|
+
toast.success(v ? t("reviews.auto_approve_on") : t("reviews.auto_approve_off"));
|
|
278
286
|
} catch (e) {
|
|
279
|
-
toast.error(e.message || "
|
|
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">
|
|
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">
|
|
144
|
+
<h3 className="text-sm font-semibold">{t("products.options")}</h3>
|
|
145
145
|
<p className="text-xs text-muted-foreground">
|
|
146
|
-
|
|
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="
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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,
|
|
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:
|
|
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="
|
|
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(
|
|
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:
|
|
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={
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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={
|
|
301
|
+
title={t("products.confirm_remove_option", { name: attributes[pendingRemoveRow]?.name })}
|
|
295
302
|
description={
|
|
296
303
|
attributes.length === 1
|
|
297
|
-
?
|
|
298
|
-
:
|
|
304
|
+
? t("products.confirm_remove_option_all", { count: variations.length })
|
|
305
|
+
: t("products.confirm_remove_option_rebuild", { count: variations.length })
|
|
299
306
|
}
|
|
300
|
-
confirmLabel="
|
|
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:
|
|
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>
|
|
408
|
+
<DialogTitle>{t("products.option_catalog")}</DialogTitle>
|
|
402
409
|
</DialogHeader>
|
|
403
410
|
<p className="-mt-2 text-xs text-muted-foreground">
|
|
404
|
-
|
|
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>
|
|
411
|
-
<span>
|
|
412
|
-
<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="
|
|
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">
|
|
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" />
|
|
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">
|
|
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>
|
|
481
|
+
<Label>{t("products.new_option")}</Label>
|
|
476
482
|
<Input
|
|
477
|
-
placeholder="
|
|
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" />
|
|
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}>
|
|
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: "
|
|
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: "
|
|
33
|
+
{ id: "modifiers", label: t("products.specifications"), body: <ModifiersTab product={product} up={up} /> },
|
|
33
34
|
...(product.downloadable
|
|
34
|
-
? [{ id: "downloads", label: "
|
|
35
|
+
? [{ id: "downloads", label: t("products.downloads"), body: <DownloadsTab product={product} up={up} /> }]
|
|
35
36
|
: []),
|
|
36
|
-
{ id: "linked", label: "
|
|
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">
|
|
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
|
-
|
|
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
|
-
|
|
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">
|
|
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>
|
|
41
|
+
<Label>{t("products.visible")}</Label>
|
|
41
42
|
<p className="text-xs text-muted-foreground">
|
|
42
|
-
{visible ? "
|
|
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
|
-
|
|
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
|
-
|
|
60
|
+
{t("common.save")}
|
|
60
61
|
</Button>
|
|
61
62
|
{onDelete && (
|
|
62
|
-
<Button variant="outline" size="icon" onClick={() => setConfirm(true)} title="
|
|
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="
|
|
73
|
-
description="
|
|
74
|
-
confirmLabel="
|
|
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
|
/>
|