setSidebarOpen(false)} />}
- {user?.email}
-
@@ -79,3 +126,11 @@ export default function AdminLayout() {
);
}
+
+export default function AdminLayout() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/features/admin/AdminSiteSettingsContext.tsx b/frontend/src/features/admin/AdminSiteSettingsContext.tsx
new file mode 100644
index 0000000..00b8233
--- /dev/null
+++ b/frontend/src/features/admin/AdminSiteSettingsContext.tsx
@@ -0,0 +1,64 @@
+import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
+import { apiFetch } from "../../api/client";
+import type { SiteSettings } from "../../api/types";
+
+const DEFAULT_SETTINGS: SiteSettings = {
+ name: "",
+ description: "",
+ orders_enabled: true,
+ header_bg_color: "#232323",
+ header_text_color: "#f2f2f2",
+ body_bg_color: "#1a1a1a",
+ body_text_color: "#f2f2f2",
+ footer_bg_color: "#232323",
+ footer_text_color: "#f2f2f2",
+ accent_color: "#ffd700",
+ product_layout: "grid",
+ product_columns: 0,
+ product_scroll: "vertical",
+ contact_card_transparent: true,
+ contact_card_bg_color: "#232323",
+ logo_media_id: null,
+ hero_media_id: null,
+ hero_pages: [],
+ customer_login_enabled: false,
+ customer_registration_enabled: false,
+ customer_verification_required: false,
+ verification_contact_link_id: null,
+};
+
+interface AdminSiteSettingsContextValue {
+ settings: SiteSettings;
+ loading: boolean;
+ // Re-fetches from the API -- called after any admin page saves settings
+ // (SiteSettingsPage, CustomerAccountsPage, ...) so nav visibility
+ // (AdminLayout) and other pages reading this context (UsersPage,
+ // CustomerVerificationsPage) update immediately, without a full reload.
+ refresh: () => void;
+}
+
+const AdminSiteSettingsContext = createContext
(undefined);
+
+export function AdminSiteSettingsProvider({ children }: { children: ReactNode }) {
+ const [settings, setSettings] = useState(DEFAULT_SETTINGS);
+ const [loading, setLoading] = useState(true);
+
+ function refresh() {
+ apiFetch("/api/admin/site-settings")
+ .then(setSettings)
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }
+
+ useEffect(refresh, []);
+
+ return (
+ {children}
+ );
+}
+
+export function useAdminSiteSettings(): AdminSiteSettingsContextValue {
+ const ctx = useContext(AdminSiteSettingsContext);
+ if (!ctx) throw new Error("useAdminSiteSettings must be used within an AdminSiteSettingsProvider");
+ return ctx;
+}
diff --git a/frontend/src/features/admin/AppearanceSettingsPage.tsx b/frontend/src/features/admin/AppearanceSettingsPage.tsx
new file mode 100644
index 0000000..68b6258
--- /dev/null
+++ b/frontend/src/features/admin/AppearanceSettingsPage.tsx
@@ -0,0 +1,324 @@
+import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react";
+import { apiFetch, apiUpload } from "../../api/client";
+import { HERO_PAGES, PRODUCT_COLUMNS, PRODUCT_LAYOUTS, PRODUCT_SCROLL_DIRECTIONS, type Media, type SiteSettings } from "../../api/types";
+import { useI18n } from "../../i18n/LanguageContext";
+import type { MessageKey } from "../../i18n/messages";
+import { useToast } from "../../ui/ToastContext";
+import AccordionSection from "../../ui/Accordion";
+
+const PRODUCT_LAYOUT_KEYS: Record<(typeof PRODUCT_LAYOUTS)[number], MessageKey> = {
+ grid: "admin.appearance.layoutGrid",
+ "grid-overlay": "admin.appearance.layoutGridOverlay",
+ "grid-minimal": "admin.appearance.layoutGridMinimal",
+ list: "admin.appearance.layoutList",
+};
+
+const PRODUCT_SCROLL_KEYS: Record<(typeof PRODUCT_SCROLL_DIRECTIONS)[number], MessageKey> = {
+ vertical: "admin.appearance.scrollVertical",
+ horizontal: "admin.appearance.scrollHorizontal",
+};
+
+const HERO_PAGE_KEYS: Record<(typeof HERO_PAGES)[number], MessageKey> = {
+ catalog: "admin.appearance.pageCatalog",
+ home: "admin.appearance.pageHome",
+ cart: "admin.appearance.pageCart",
+ checkout: "admin.appearance.pageCheckout",
+ account: "admin.appearance.pageAccount",
+ contact: "admin.appearance.pageContact",
+};
+
+export default function AppearanceSettingsPage() {
+ const { t } = useI18n();
+ const toast = useToast();
+ const [settings, setSettings] = useState(null);
+ const [mediaLibrary, setMediaLibrary] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [loadError, setLoadError] = useState(null);
+ const uploadTargetRef = useRef<"logo" | "hero" | null>(null);
+ const fileInputRef = useRef(null);
+
+ function load() {
+ setLoading(true);
+ Promise.all([apiFetch("/api/admin/site-settings"), apiFetch<{ media: Media[] }>("/api/admin/media")])
+ .then(([s, m]) => {
+ setSettings(s);
+ setMediaLibrary(m.media);
+ })
+ .catch(() => setLoadError(t("admin.appearance.loadError")))
+ .finally(() => setLoading(false));
+ }
+
+ useEffect(load, []);
+
+ async function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ if (!settings) return;
+ setSaving(true);
+ try {
+ const updated = await apiFetch("/api/admin/site-settings/appearance", {
+ method: "PUT",
+ body: JSON.stringify({
+ header_bg_color: settings.header_bg_color,
+ header_text_color: settings.header_text_color,
+ body_bg_color: settings.body_bg_color,
+ body_text_color: settings.body_text_color,
+ footer_bg_color: settings.footer_bg_color,
+ footer_text_color: settings.footer_text_color,
+ accent_color: settings.accent_color,
+ product_layout: settings.product_layout,
+ product_columns: settings.product_columns,
+ product_scroll: settings.product_scroll,
+ contact_card_transparent: settings.contact_card_transparent,
+ contact_card_bg_color: settings.contact_card_bg_color,
+ logo_media_id: settings.logo_media_id,
+ clear_logo: settings.logo_media_id === null,
+ hero_media_id: settings.hero_media_id,
+ clear_hero: settings.hero_media_id === null,
+ hero_pages: settings.hero_pages,
+ }),
+ });
+ setSettings(updated);
+ toast.success(t("admin.appearance.saved"));
+ } catch {
+ toast.error(t("admin.appearance.saveError"));
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ function triggerUpload(target: "logo" | "hero") {
+ uploadTargetRef.current = target;
+ fileInputRef.current?.click();
+ }
+
+ async function handleFileSelected(e: ChangeEvent) {
+ const file = e.target.files?.[0];
+ const target = uploadTargetRef.current;
+ e.target.value = "";
+ if (!file || !target || !settings) return;
+
+ setUploading(true);
+ try {
+ const formData = new FormData();
+ formData.append("file", file);
+ const uploaded = await apiUpload("/api/admin/media", formData);
+ setMediaLibrary((current) => [uploaded, ...current]);
+ setSettings({ ...settings, [target === "logo" ? "logo_media_id" : "hero_media_id"]: uploaded.id });
+ } catch {
+ toast.error(t("admin.appearance.imageUploadError"));
+ } finally {
+ setUploading(false);
+ }
+ }
+
+ function toggleHeroPage(page: string) {
+ if (!settings) return;
+ const has = settings.hero_pages.includes(page);
+ setSettings({
+ ...settings,
+ hero_pages: has ? settings.hero_pages.filter((p) => p !== page) : [...settings.hero_pages, page],
+ });
+ }
+
+ if (loading || !settings) return {t("common.loading")}
;
+
+ function colorField(key: keyof SiteSettings, label: string) {
+ const value = settings![key] as string;
+ return (
+
+
+
+ setSettings({ ...settings!, [key]: e.target.value })}
+ />
+ {value}
+
+
+ );
+ }
+
+ function imagePicker(target: "logo" | "hero", currentId: string | null, onSelect: (id: string | null) => void) {
+ return (
+
+
+
onSelect(null)}
+ style={{
+ cursor: "pointer",
+ border: currentId === null ? "2px solid var(--color-primary)" : "1px solid var(--color-border)",
+ borderRadius: 6,
+ padding: 4,
+ height: 60,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ fontSize: "0.7rem",
+ }}
+ >
+ {t("common.none")}
+
+ {mediaLibrary
+ .filter((m) => m.mime_type.startsWith("image/"))
+ .map((m) => (
+
onSelect(m.id)}
+ style={{
+ cursor: "pointer",
+ border: currentId === m.id ? "2px solid var(--color-primary)" : "1px solid var(--color-border)",
+ borderRadius: 6,
+ padding: 4,
+ }}
+ >
+

+
+ ))}
+
+
triggerUpload(target)} disabled={uploading}>
+ {uploading ? t("common.uploading") : t("admin.appearance.uploadImage")}
+
+
+ );
+ }
+
+ return (
+
+
{t("admin.appearance.title")}
+
+ {loadError &&
{loadError}
}
+
+
+
+
+ );
+}
diff --git a/frontend/src/features/admin/CategoriesPage.tsx b/frontend/src/features/admin/CategoriesPage.tsx
index c6daae2..ba85893 100644
--- a/frontend/src/features/admin/CategoriesPage.tsx
+++ b/frontend/src/features/admin/CategoriesPage.tsx
@@ -1,40 +1,87 @@
-import { useEffect, useState, type FormEvent } from "react";
-import { apiFetch } from "../../api/client";
+import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react";
+import { apiFetch, apiUpload } from "../../api/client";
import { swapPosition } from "../../api/reorder";
-import type { Category } from "../../api/types";
+import type { Category, Media } from "../../api/types";
+import { useI18n } from "../../i18n/LanguageContext";
+import { useConfirm } from "../../ui/ConfirmContext";
+import { useToast } from "../../ui/ToastContext";
+import Modal from "../../ui/Modal";
-const emptyForm = { name: "", slug: "", description: "", position: 0, is_active: true };
+const emptyForm = { name: "", description: "", position: 0, is_active: true, media_id: null as string | null };
export default function CategoriesPage() {
+ const { t } = useI18n();
+ const confirm = useConfirm();
+ const toast = useToast();
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
+ const [modalOpen, setModalOpen] = useState(false);
+ const [mediaLibrary, setMediaLibrary] = useState([]);
+ const [uploading, setUploading] = useState(false);
+ const fileInputRef = useRef(null);
function load() {
setLoading(true);
- apiFetch<{ categories: Category[] }>("/api/admin/categories")
- .then((data) => setCategories(data.categories))
- .catch(() => setError("Failed to load categories."))
+ Promise.all([
+ apiFetch<{ categories: Category[] }>("/api/admin/categories"),
+ apiFetch<{ media: Media[] }>("/api/admin/media"),
+ ])
+ .then(([c, m]) => {
+ setCategories(c.categories);
+ setMediaLibrary(m.media);
+ })
+ .catch(() => setError(t("admin.categories.loadError")))
.finally(() => setLoading(false));
}
useEffect(load, []);
- function startEdit(cat: Category) {
+ function openCreateModal() {
+ setError(null);
+ setEditingId(null);
+ setForm({ ...emptyForm, position: categories.length });
+ setModalOpen(true);
+ }
+
+ function openEditModal(cat: Category) {
+ setError(null);
setEditingId(cat.id);
setForm({
name: cat.name,
- slug: cat.slug,
description: cat.description,
position: cat.position,
is_active: cat.is_active,
+ media_id: cat.media_id,
});
+ setModalOpen(true);
}
- function cancelEdit() {
+ async function handleFileSelected(e: ChangeEvent) {
+ const file = e.target.files?.[0];
+ e.target.value = "";
+ if (!file) return;
+
+ setUploading(true);
+ try {
+ const formData = new FormData();
+ formData.append("file", file);
+ const uploaded = await apiUpload("/api/admin/media", formData);
+ setMediaLibrary((current) => [uploaded, ...current]);
+ setForm((f) => ({ ...f, media_id: uploaded.id }));
+ } catch {
+ toast.error(t("admin.appearance.imageUploadError"));
+ } finally {
+ setUploading(false);
+ }
+ }
+
+ function closeModal() {
+ if (saving) return;
+ setModalOpen(false);
setEditingId(null);
setForm(emptyForm);
}
@@ -42,29 +89,32 @@ export default function CategoriesPage() {
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setSaving(true);
- setError(null);
try {
if (editingId) {
await apiFetch(`/api/admin/categories/${editingId}`, { method: "PUT", body: JSON.stringify(form) });
} else {
await apiFetch("/api/admin/categories", { method: "POST", body: JSON.stringify(form) });
}
- cancelEdit();
+ setModalOpen(false);
+ setEditingId(null);
+ setForm(emptyForm);
load();
+ toast.success(t("common.savedToast"));
} catch {
- setError("Failed to save category (slug may already be in use).");
+ toast.error(t("admin.categories.saveError"));
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
- if (!confirm("Delete this category?")) return;
+ if (!(await confirm({ message: t("admin.categories.confirmDelete"), danger: true }))) return;
try {
await apiFetch(`/api/admin/categories/${id}`, { method: "DELETE" });
load();
+ toast.success(t("common.deletedToast"));
} catch {
- setError("Failed to delete category (it may still have products attached).");
+ toast.error(t("admin.categories.deleteError"));
}
}
@@ -78,78 +128,129 @@ export default function CategoriesPage() {
);
load();
} catch {
- setError("Failed to reorder categories.");
+ toast.error(t("admin.categories.reorderError"));
}
}
return (
-
Categories
+
{t("admin.categories.title")}
{error &&
{error}
}
-
-
{editingId ? "Edit category" : "New category"}
-
+
+
+ {t("admin.categories.createButton")}
+
+
+
+
{editingId ? t("admin.categories.editTitle") : t("admin.categories.newTitle")}
+
+
+
+
{loading ? (
-
Loading…
+
{t("common.loading")}
) : (
- | Name |
- Slug |
- Position |
- Status |
+ {t("admin.categories.name")} |
+ {t("admin.categories.position")} |
+ {t("admin.categories.colStatus")} |
|
@@ -157,13 +258,12 @@ export default function CategoriesPage() {
{categories.map((cat, index) => (
| {cat.name} |
- {cat.slug} |
handleMove(index, "up")}
>
@@ -172,7 +272,7 @@ export default function CategoriesPage() {
handleMove(index, "down")}
>
@@ -183,15 +283,15 @@ export default function CategoriesPage() {
|
- {cat.is_active ? "active" : "inactive"}
+ {cat.is_active ? t("common.active") : t("common.inactive")}
|
- startEdit(cat)}>
- Edit
+ openEditModal(cat)}>
+ {t("common.edit")}
{" "}
handleDelete(cat.id)}>
- Delete
+ {t("common.delete")}
|
diff --git a/frontend/src/features/admin/ContactLinksPage.tsx b/frontend/src/features/admin/ContactLinksPage.tsx
new file mode 100644
index 0000000..0b023e4
--- /dev/null
+++ b/frontend/src/features/admin/ContactLinksPage.tsx
@@ -0,0 +1,389 @@
+import { useEffect, useRef, useState, type FormEvent } from "react";
+import { apiFetch, apiUpload } from "../../api/client";
+import { swapPosition } from "../../api/reorder";
+import type { ContactLink, Media } from "../../api/types";
+import { getSocialIcon, SocialIconGlyph, SOCIAL_ICONS } from "../../socialIcons";
+import { useI18n } from "../../i18n/LanguageContext";
+import { useConfirm } from "../../ui/ConfirmContext";
+import { useToast } from "../../ui/ToastContext";
+
+const emptyForm = {
+ label: "",
+ url: "",
+ icon_key: "",
+ icon_media_id: null as string | null,
+ color: "#25D366",
+ position: 0,
+ is_active: true,
+};
+
+export default function ContactLinksPage() {
+ const { t } = useI18n();
+ const confirm = useConfirm();
+ const toast = useToast();
+ const [links, setLinks] = useState([]);
+ const [mediaLibrary, setMediaLibrary] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [editingId, setEditingId] = useState(null);
+ const [form, setForm] = useState(emptyForm);
+ const [saving, setSaving] = useState(false);
+ const [uploadingIcon, setUploadingIcon] = useState(false);
+ const iconFileInputRef = useRef(null);
+
+ function load() {
+ setLoading(true);
+ Promise.all([
+ apiFetch<{ contact_links: ContactLink[] }>("/api/admin/contact-links"),
+ apiFetch<{ media: Media[] }>("/api/admin/media"),
+ ])
+ .then(([l, m]) => {
+ setLinks(l.contact_links);
+ setMediaLibrary(m.media);
+ })
+ .catch(() => setError(t("admin.contactLinks.loadError")))
+ .finally(() => setLoading(false));
+ }
+
+ useEffect(load, []);
+
+ function mediaIconUrl(mediaId: string | null) {
+ return mediaId ? mediaLibrary.find((m) => m.id === mediaId)?.url : undefined;
+ }
+
+ function pickBrandIcon(key: string) {
+ const icon = getSocialIcon(key);
+ setForm({ ...form, icon_key: key, icon_media_id: null, color: icon?.defaultColor ?? form.color });
+ }
+
+ function pickCustomIcon(mediaId: string | null) {
+ setForm({ ...form, icon_key: "", icon_media_id: mediaId });
+ }
+
+ // Custom icons are uploaded directly here (no product_id -- they aren't
+ // tied to any product) instead of through a separate media library page.
+ async function handleUploadIcon() {
+ const file = iconFileInputRef.current?.files?.[0];
+ if (!file) return;
+
+ setUploadingIcon(true);
+ try {
+ const formData = new FormData();
+ formData.append("file", file);
+ const uploaded = await apiUpload("/api/admin/media", formData);
+ if (iconFileInputRef.current) iconFileInputRef.current.value = "";
+ setMediaLibrary((current) => [uploaded, ...current]);
+ pickCustomIcon(uploaded.id);
+ toast.success(t("common.savedToast"));
+ } catch {
+ toast.error(t("admin.contactLinks.iconUploadError"));
+ } finally {
+ setUploadingIcon(false);
+ }
+ }
+
+ async function handleDeleteIcon(mediaId: string) {
+ if (!(await confirm({ message: t("admin.media.confirmDelete"), danger: true }))) return;
+ try {
+ await apiFetch(`/api/admin/media/${mediaId}`, { method: "DELETE" });
+ setMediaLibrary((current) => current.filter((m) => m.id !== mediaId));
+ if (form.icon_media_id === mediaId) pickCustomIcon(null);
+ toast.success(t("common.deletedToast"));
+ } catch {
+ toast.error(t("admin.media.deleteError"));
+ }
+ }
+
+ function startEdit(link: ContactLink) {
+ setEditingId(link.id);
+ setForm({
+ label: link.label,
+ url: link.url,
+ icon_key: link.icon_key,
+ icon_media_id: link.icon_media_id,
+ color: link.color || "#25D366",
+ position: link.position,
+ is_active: link.is_active,
+ });
+ }
+
+ function cancelEdit() {
+ setEditingId(null);
+ setForm(emptyForm);
+ }
+
+ async function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ setSaving(true);
+ try {
+ if (editingId) {
+ await apiFetch(`/api/admin/contact-links/${editingId}`, { method: "PUT", body: JSON.stringify(form) });
+ } else {
+ await apiFetch("/api/admin/contact-links", { method: "POST", body: JSON.stringify(form) });
+ }
+ cancelEdit();
+ load();
+ toast.success(t("common.savedToast"));
+ } catch {
+ toast.error(t("admin.contactLinks.saveError"));
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleDelete(id: string) {
+ if (!(await confirm({ message: t("admin.contactLinks.confirmDelete"), danger: true }))) return;
+ try {
+ await apiFetch(`/api/admin/contact-links/${id}`, { method: "DELETE" });
+ load();
+ toast.success(t("common.deletedToast"));
+ } catch {
+ toast.error(t("admin.contactLinks.deleteError"));
+ }
+ }
+
+ async function handleMove(index: number, direction: "up" | "down") {
+ try {
+ await swapPosition(links, index, direction, (id, position) =>
+ apiFetch(`/api/admin/contact-links/${id}/position`, {
+ method: "PATCH",
+ body: JSON.stringify({ position }),
+ }),
+ );
+ load();
+ } catch {
+ toast.error(t("admin.contactLinks.reorderError"));
+ }
+ }
+
+ function renderPreviewIcon(link: ContactLink) {
+ const brandIcon = getSocialIcon(link.icon_key);
+ const mediaUrl = mediaIconUrl(link.icon_media_id);
+ if (brandIcon) {
+ return (
+
+
+
+ );
+ }
+ if (mediaUrl) {
+ return
;
+ }
+ return "—";
+ }
+
+ return (
+
+
{t("admin.contactLinks.title")}
+ {error &&
{error}
}
+
+
+
{editingId ? t("admin.contactLinks.editTitle") : t("admin.contactLinks.newTitle")}
+
+
+
+ {loading ? (
+
{t("common.loading")}
+ ) : (
+
+
+
+ | {t("admin.contactLinks.colIcon")} |
+ {t("admin.contactLinks.colLabel")} |
+ {t("admin.contactLinks.colUrl")} |
+ {t("admin.contactLinks.colColor")} |
+ {t("admin.contactLinks.colPosition")} |
+ {t("admin.contactLinks.colStatus")} |
+ |
+
+
+
+ {links.map((link, index) => (
+
+ | {renderPreviewIcon(link)} |
+ {link.label} |
+ {link.url} |
+
+
+
+ {link.color}
+
+ |
+
+
+ handleMove(index, "up")}
+ >
+ ↑
+
+ handleMove(index, "down")}
+ >
+ ↓
+
+ {link.position}
+
+ |
+
+
+ {link.is_active ? t("common.active") : t("common.inactive")}
+
+ |
+
+ startEdit(link)}>
+ {t("common.edit")}
+ {" "}
+ handleDelete(link.id)}>
+ {t("common.delete")}
+
+ |
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/features/admin/CustomerAccountsPage.tsx b/frontend/src/features/admin/CustomerAccountsPage.tsx
new file mode 100644
index 0000000..3db7014
--- /dev/null
+++ b/frontend/src/features/admin/CustomerAccountsPage.tsx
@@ -0,0 +1,141 @@
+import { useEffect, useState, type FormEvent } from "react";
+import { Link } from "react-router-dom";
+import { apiFetch } from "../../api/client";
+import type { ContactLink, SiteSettings } from "../../api/types";
+import { useI18n } from "../../i18n/LanguageContext";
+import { useToast } from "../../ui/ToastContext";
+import { useAdminSiteSettings } from "./AdminSiteSettingsContext";
+
+export default function CustomerAccountsPage() {
+ const { t } = useI18n();
+ const toast = useToast();
+ const { refresh: refreshNavSettings } = useAdminSiteSettings();
+ const [settings, setSettings] = useState(null);
+ const [contactLinks, setContactLinks] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [loadError, setLoadError] = useState(null);
+
+ function load() {
+ setLoading(true);
+ Promise.all([
+ apiFetch("/api/admin/site-settings"),
+ apiFetch<{ contact_links: ContactLink[] }>("/api/admin/contact-links"),
+ ])
+ .then(([s, c]) => {
+ setSettings(s);
+ setContactLinks(c.contact_links);
+ })
+ .catch(() => setLoadError(t("admin.customerAccounts.loadError")))
+ .finally(() => setLoading(false));
+ }
+
+ useEffect(load, []);
+
+ async function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ if (!settings) return;
+ setSaving(true);
+ try {
+ const updated = await apiFetch("/api/admin/site-settings/customer-auth", {
+ method: "PUT",
+ body: JSON.stringify({
+ login_enabled: settings.customer_login_enabled,
+ registration_enabled: settings.customer_registration_enabled,
+ verification_required: settings.customer_verification_required,
+ clear_verification_contact_link: settings.verification_contact_link_id === null,
+ verification_contact_link_id: settings.verification_contact_link_id,
+ }),
+ });
+ setSettings(updated);
+ refreshNavSettings();
+ toast.success(t("admin.customerAccounts.saved"));
+ } catch {
+ toast.error(t("admin.customerAccounts.saveError"));
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ if (loading || !settings) return {t("common.loading")}
;
+
+ return (
+
+
{t("admin.customerAccounts.title")}
+ {loadError &&
{loadError}
}
+
+
+
+
+
+ {settings.customer_verification_required && (
+
+
+ {t("admin.customerAccounts.reviewHint")}{" "}
+ {t("admin.customerAccounts.reviewLink")}.
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/features/admin/CustomerVerificationsPage.tsx b/frontend/src/features/admin/CustomerVerificationsPage.tsx
new file mode 100644
index 0000000..38f8f55
--- /dev/null
+++ b/frontend/src/features/admin/CustomerVerificationsPage.tsx
@@ -0,0 +1,204 @@
+import { useEffect, useState } from "react";
+import { getAccessToken } from "../../api/client";
+import { apiFetch } from "../../api/client";
+import type { CustomerVerification } from "../../api/types";
+import { useI18n } from "../../i18n/LanguageContext";
+import type { MessageKey } from "../../i18n/messages";
+import { useToast } from "../../ui/ToastContext";
+import { useAdminSiteSettings } from "./AdminSiteSettingsContext";
+
+const STATUSES: CustomerVerification["status"][] = ["pending", "approved", "rejected"];
+
+const STATUS_KEYS: Record = {
+ pending: "admin.customerVerifications.statusPending",
+ approved: "admin.customerVerifications.statusApproved",
+ rejected: "admin.customerVerifications.statusRejected",
+};
+
+// Verification documents are served through an authenticated,
+// admin/owner-only endpoint (never a public URL, unlike the media module),
+// so they have to be fetched with the bearer token and turned into a
+// blob: URL for
to display -- a plain
would send
+// no Authorization header and get a 401.
+function useAdminDocumentUrl(verificationId: string, side: "front" | "back"): string | undefined {
+ const [url, setUrl] = useState(undefined);
+
+ useEffect(() => {
+ let objectUrl: string | undefined;
+ let cancelled = false;
+
+ fetch(`/api/admin/customer-verifications/${verificationId}/document/${side}`, {
+ headers: { Authorization: `Bearer ${getAccessToken() ?? ""}` },
+ })
+ .then((res) => (res.ok ? res.blob() : Promise.reject(new Error("failed"))))
+ .then((blob) => {
+ if (cancelled) return;
+ objectUrl = URL.createObjectURL(blob);
+ setUrl(objectUrl);
+ })
+ .catch(() => setUrl(undefined));
+
+ return () => {
+ cancelled = true;
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
+ };
+ }, [verificationId, side]);
+
+ return url;
+}
+
+function DocumentImage({ verificationId, side }: { verificationId: string; side: "front" | "back" }) {
+ const { t } = useI18n();
+ const url = useAdminDocumentUrl(verificationId, side);
+ return (
+
+
+ {side === "front" ? t("admin.customerVerifications.front") : t("admin.customerVerifications.back")}
+
+ {url ? (
+

+ ) : (
+
{t("common.loading")}
+ )}
+
+ );
+}
+
+function ReviewPanel({ verification, onReviewed }: { verification: CustomerVerification; onReviewed: () => void }) {
+ const { t } = useI18n();
+ const toast = useToast();
+ const [note, setNote] = useState(verification.admin_note);
+ const [submitting, setSubmitting] = useState(false);
+
+ async function review(status: CustomerVerification["status"]) {
+ setSubmitting(true);
+ try {
+ await apiFetch(`/api/admin/customer-verifications/${verification.id}`, {
+ method: "PATCH",
+ body: JSON.stringify({ status, admin_note: note }),
+ });
+ onReviewed();
+ toast.success(t("common.savedToast"));
+ } catch {
+ toast.error(t("admin.customerVerifications.reviewError"));
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ review("approved")}>
+ {t("admin.customerVerifications.approve")}
+
+ review("rejected")}>
+ {t("admin.customerVerifications.reject")}
+
+
+
+ );
+}
+
+export default function CustomerVerificationsPage() {
+ const { t } = useI18n();
+ const { settings } = useAdminSiteSettings();
+ const [status, setStatus] = useState("pending");
+ const [list, setList] = useState([]);
+ const [selectedId, setSelectedId] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ function load() {
+ setLoading(true);
+ const query = status ? `?status=${status}` : "";
+ apiFetch<{ verifications: CustomerVerification[] }>(`/api/admin/customer-verifications${query}`)
+ .then((data) => setList(data.verifications))
+ .catch(() => setError(t("admin.customerVerifications.loadError")))
+ .finally(() => setLoading(false));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }
+
+ useEffect(load, [status]);
+
+ const selected = list.find((v) => v.id === selectedId) ?? null;
+
+ if (!settings.customer_verification_required) {
+ return (
+
+
{t("admin.customerVerifications.title")}
+
{t("admin.customerVerifications.disabledHint")}
+
+ );
+ }
+
+ return (
+
+
{t("admin.customerVerifications.title")}
+ {error &&
{error}
}
+
+
+
+
+
+ {loading ? (
+
{t("common.loading")}
+ ) : list.length === 0 ? (
+
{t("admin.customerVerifications.none")}
+ ) : (
+
+
+
+ | {t("admin.customerVerifications.colUserId")} |
+ {t("admin.customerVerifications.colStatus")} |
+ {t("admin.customerVerifications.colSubmitted")} |
+ |
+
+
+
+ {list.map((v) => (
+
+ | {v.user_id} |
+
+ {t(STATUS_KEYS[v.status])}
+ |
+ {new Date(v.created_at).toLocaleString()} |
+
+ setSelectedId(v.id)}>
+ {t("admin.customerVerifications.review")}
+
+ |
+
+ ))}
+
+
+ )}
+
+ {selected && (
+
{
+ setSelectedId(null);
+ load();
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/features/admin/DashboardPage.tsx b/frontend/src/features/admin/DashboardPage.tsx
index 7202353..183463c 100644
--- a/frontend/src/features/admin/DashboardPage.tsx
+++ b/frontend/src/features/admin/DashboardPage.tsx
@@ -1,15 +1,68 @@
-import { useAuth } from "../auth/AuthContext";
+import { useEffect, useState } from "react";
+import { apiFetch } from "../../api/client";
+import type { Category, Order, Product, User } from "../../api/types";
+import { useI18n } from "../../i18n/LanguageContext";
+import { useAdminSiteSettings } from "./AdminSiteSettingsContext";
+
+function StatCard({ label, value }: { label: string; value: number | null }) {
+ return (
+
+
{value === null ? "—" : value}
+
{label}
+
+ );
+}
export default function DashboardPage() {
- const { user } = useAuth();
+ const { t } = useI18n();
+ const { settings } = useAdminSiteSettings();
+ const customerAccountsEnabled = settings.customer_login_enabled || settings.customer_registration_enabled;
+
+ const [categoryCount, setCategoryCount] = useState(null);
+ const [productCount, setProductCount] = useState(null);
+ const [customerCount, setCustomerCount] = useState(null);
+ const [orderCount, setOrderCount] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ apiFetch<{ categories: Category[] }>("/api/admin/categories")
+ .then((data) => setCategoryCount(data.categories.length))
+ .catch(() => setError(t("admin.dashboard.loadError")));
+
+ apiFetch<{ products: Product[] }>("/api/admin/products")
+ .then((data) => setProductCount(data.products.length))
+ .catch(() => setError(t("admin.dashboard.loadError")));
+
+ if (customerAccountsEnabled) {
+ apiFetch<{ users: User[] }>("/api/admin/users")
+ .then((data) => setCustomerCount(data.users.filter((u) => u.role === "customer").length))
+ .catch(() => setError(t("admin.dashboard.loadError")));
+ }
+
+ if (settings.orders_enabled) {
+ apiFetch<{ orders: Order[] }>("/api/admin/orders")
+ .then((data) => setOrderCount(data.orders.length))
+ .catch(() => setError(t("admin.dashboard.loadError")));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [customerAccountsEnabled, settings.orders_enabled]);
+
return (
-
Dashboard
-
-
- Signed in as {user?.email} ({user?.role}).
-
-
Use the sidebar to configure the site: identity, catalog, orders and notifications.
+
{t("admin.dashboard.title")}
+ {error &&
{error}
}
+
+
+
+
+ {customerAccountsEnabled && }
+ {settings.orders_enabled && }
);
diff --git a/frontend/src/features/admin/MediaPage.tsx b/frontend/src/features/admin/MediaPage.tsx
deleted file mode 100644
index 6f39ccc..0000000
--- a/frontend/src/features/admin/MediaPage.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-import { useEffect, useRef, useState, type FormEvent } from "react";
-import { apiFetch, apiUpload } from "../../api/client";
-import type { Media } from "../../api/types";
-
-export default function MediaPage() {
- const [media, setMedia] = useState
([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [uploading, setUploading] = useState(false);
- const [altText, setAltText] = useState("");
- const fileInputRef = useRef(null);
-
- function load() {
- setLoading(true);
- apiFetch<{ media: Media[] }>("/api/admin/media")
- .then((data) => setMedia(data.media))
- .catch(() => setError("Failed to load media."))
- .finally(() => setLoading(false));
- }
-
- useEffect(load, []);
-
- async function handleUpload(e: FormEvent) {
- e.preventDefault();
- const file = fileInputRef.current?.files?.[0];
- if (!file) return;
-
- setUploading(true);
- setError(null);
- try {
- const formData = new FormData();
- formData.append("file", file);
- formData.append("alt_text", altText);
- await apiUpload("/api/admin/media", formData);
- setAltText("");
- if (fileInputRef.current) fileInputRef.current.value = "";
- load();
- } catch {
- setError("Upload failed (check file type/size).");
- } finally {
- setUploading(false);
- }
- }
-
- async function handleDelete(id: string) {
- if (!confirm("Delete this media file?")) return;
- try {
- await apiFetch(`/api/admin/media/${id}`, { method: "DELETE" });
- load();
- } catch {
- setError("Failed to delete media.");
- }
- }
-
- return (
-
-
Media
- {error &&
{error}
}
-
-
-
Upload
-
-
-
- {loading ? (
-
Loading…
- ) : (
-
- {media.map((m) => (
-
- {m.mime_type.startsWith("image/") ? (
-

- ) : (
-
- {m.mime_type}
-
- )}
-
{m.filename}
-
handleDelete(m.id)}>
- Delete
-
-
- ))}
-
- )}
-
- );
-}
diff --git a/frontend/src/features/admin/OrderDetailPage.tsx b/frontend/src/features/admin/OrderDetailPage.tsx
index c794665..9664b94 100644
--- a/frontend/src/features/admin/OrderDetailPage.tsx
+++ b/frontend/src/features/admin/OrderDetailPage.tsx
@@ -1,82 +1,60 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { apiFetch } from "../../api/client";
-import { formatCents, ORDER_STATUSES, type Order } from "../../api/types";
+import { formatCents, type Order } from "../../api/types";
+import { useI18n } from "../../i18n/LanguageContext";
export default function OrderDetailPage() {
+ const { t } = useI18n();
const { id } = useParams<{ id: string }>();
const [order, setOrder] = useState(null);
const [loading, setLoading] = useState(true);
- const [updating, setUpdating] = useState(false);
- const [error, setError] = useState(null);
+ const [loadError, setLoadError] = useState(null);
function load() {
setLoading(true);
apiFetch(`/api/admin/orders/${id}`)
.then(setOrder)
- .catch(() => setError("Failed to load order."))
+ .catch(() => setLoadError(t("admin.orderDetail.loadError")))
.finally(() => setLoading(false));
}
useEffect(load, [id]);
- async function handleStatusChange(newStatus: string) {
- setUpdating(true);
- setError(null);
- try {
- const updated = await apiFetch(`/api/admin/orders/${id}/status`, {
- method: "PATCH",
- body: JSON.stringify({ status: newStatus }),
- });
- setOrder((prev) => (prev ? { ...prev, status: updated.status } : updated));
- } catch {
- setError("Failed to update order status.");
- } finally {
- setUpdating(false);
- }
- }
-
- if (loading) return Loading…
;
- if (!order) return Order not found.
;
+ if (loading) return {t("common.loading")}
;
+ if (!order) return {t("admin.orderDetail.notFound")}
;
return (
- ← Back to orders
+ ← {t("admin.orderDetail.backLink")}
-
Order {order.id.slice(0, 8)}
- {error &&
{error}
}
+
{t("admin.orderDetail.title", { id: order.id.slice(0, 8) })}
+ {loadError &&
{loadError}
}
-
Customer
+
{t("admin.orderDetail.customerTitle")}
{order.customer_name} — {order.customer_email}
{order.customer_phone && ` — ${order.customer_phone}`}
- {order.notes &&
Notes: {order.notes}
}
-
-
-
-
-
+ {order.notes && (
+
+ {t("admin.orderDetail.notes")} {order.notes}
+
+ )}
-
Items
+
{t("admin.orderDetail.itemsTitle")}
- | Product |
- Quantity |
- Multiplier |
- Unit price |
- Total |
+ {t("admin.orderDetail.colProduct")} |
+ {t("admin.orderDetail.colQuantity")} |
+ {t("admin.orderDetail.colMultiplier")} |
+ {t("admin.orderDetail.colUnitPrice")} |
+ {t("admin.orderDetail.colTotal")} |
@@ -94,7 +72,7 @@ export default function OrderDetailPage() {
- Total: {formatCents(order.total_cents)}
+ {t("admin.orderDetail.total", { amount: formatCents(order.total_cents) })}
diff --git a/frontend/src/features/admin/OrdersPage.tsx b/frontend/src/features/admin/OrdersPage.tsx
index 39f5690..520bc99 100644
--- a/frontend/src/features/admin/OrdersPage.tsx
+++ b/frontend/src/features/admin/OrdersPage.tsx
@@ -1,49 +1,37 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { apiFetch } from "../../api/client";
-import { formatCents, ORDER_STATUSES, type Order } from "../../api/types";
+import { formatCents, type Order } from "../../api/types";
+import { useI18n } from "../../i18n/LanguageContext";
export default function OrdersPage() {
+ const { t } = useI18n();
const [orders, setOrders] = useState([]);
- const [status, setStatus] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
- const query = status ? `?status=${status}` : "";
- apiFetch<{ orders: Order[] }>(`/api/admin/orders${query}`)
+ apiFetch<{ orders: Order[] }>("/api/admin/orders")
.then((data) => setOrders(data.orders))
- .catch(() => setError("Failed to load orders."))
+ .catch(() => setError(t("admin.orders.loadError")))
.finally(() => setLoading(false));
- }, [status]);
+ }, [t]);
return (
-
Orders
+
{t("admin.orders.title")}
{error &&
{error}
}
-
-
-
-
{loading ? (
-
Loading…
+
{t("common.loading")}
) : (
- | Customer |
- Email |
- Status |
- Total |
+ {t("admin.orders.colCustomer")} |
+ {t("admin.orders.colEmail")} |
+ {t("admin.orders.colTotal")} |
|
@@ -52,13 +40,10 @@ export default function OrdersPage() {
| {o.customer_name} |
{o.customer_email} |
-
- {o.status}
- |
{formatCents(o.total_cents)} |
- View
+ {t("admin.orders.view")}
|
diff --git a/frontend/src/features/admin/ProductEditPage.tsx b/frontend/src/features/admin/ProductEditPage.tsx
index 77a9e55..836f8c9 100644
--- a/frontend/src/features/admin/ProductEditPage.tsx
+++ b/frontend/src/features/admin/ProductEditPage.tsx
@@ -1,9 +1,15 @@
-import { useEffect, useState, type FormEvent } from "react";
+import { useEffect, useRef, useState, type FormEvent } from "react";
import { Link, useParams } from "react-router-dom";
-import { apiFetch } from "../../api/client";
+import { apiFetch, apiUpload } from "../../api/client";
import { formatCents, type Category, type Media, type PriceTier, type Product, type Unit } from "../../api/types";
+import { useI18n } from "../../i18n/LanguageContext";
+import { useConfirm } from "../../ui/ConfirmContext";
+import { useToast } from "../../ui/ToastContext";
export default function ProductEditPage() {
+ const { t } = useI18n();
+ const confirm = useConfirm();
+ const toast = useToast();
const { id } = useParams<{ id: string }>();
const [product, setProduct] = useState(null);
const [categories, setCategories] = useState([]);
@@ -13,7 +19,9 @@ export default function ProductEditPage() {
const [gallery, setGallery] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
- const [message, setMessage] = useState<{ type: "error" | "success"; text: string } | null>(null);
+ const [loadError, setLoadError] = useState(null);
+ const [uploading, setUploading] = useState(false);
+ const fileInputRef = useRef(null);
function load() {
if (!id) return;
@@ -23,18 +31,18 @@ export default function ProductEditPage() {
apiFetch<{ categories: Category[] }>("/api/admin/categories"),
apiFetch<{ units: Unit[] }>("/api/admin/units"),
apiFetch<{ price_tiers: PriceTier[] }>(`/api/admin/products/${id}/price-tiers`),
- apiFetch<{ media: Media[] }>("/api/admin/media"),
+ apiFetch<{ media: Media[] }>(`/api/admin/media?product_id=${id}`),
apiFetch<{ gallery: { media_id: string }[] }>(`/api/admin/products/${id}/gallery`),
])
- .then(([p, c, u, t, m, g]) => {
+ .then(([p, c, u, tr, m, g]) => {
setProduct(p);
setCategories(c.categories);
setUnits(u.units);
- setTiers(t.price_tiers);
+ setTiers(tr.price_tiers);
setMediaLibrary(m.media);
setGallery(g.gallery.map((item) => item.media_id));
})
- .catch(() => setMessage({ type: "error", text: "Failed to load product." }))
+ .catch(() => setLoadError(t("admin.productEdit.loadError")))
.finally(() => setLoading(false));
}
@@ -44,16 +52,15 @@ export default function ProductEditPage() {
e.preventDefault();
if (!product) return;
setSaving(true);
- setMessage(null);
try {
const updated = await apiFetch(`/api/admin/products/${id}`, {
method: "PUT",
body: JSON.stringify(product),
});
setProduct(updated);
- setMessage({ type: "success", text: "Product saved." });
+ toast.success(t("admin.productEdit.saved"));
} catch {
- setMessage({ type: "error", text: "Failed to save product (slug may already be in use)." });
+ toast.error(t("admin.productEdit.saveError"));
} finally {
setSaving(false);
}
@@ -74,34 +81,77 @@ export default function ProductEditPage() {
});
e.currentTarget.reset();
load();
+ toast.success(t("common.savedToast"));
} catch {
- setMessage({ type: "error", text: "Failed to add price tier." });
+ toast.error(t("admin.productEdit.addTierError"));
}
}
async function handleDeleteTier(tierId: string) {
- if (!confirm("Delete this price tier?")) return;
+ if (!(await confirm({ message: t("admin.productEdit.confirmDeleteTier"), danger: true }))) return;
try {
await apiFetch(`/api/admin/products/${id}/price-tiers/${tierId}`, { method: "DELETE" });
load();
+ toast.success(t("common.deletedToast"));
} catch {
- setMessage({ type: "error", text: "Failed to delete price tier." });
+ toast.error(t("admin.productEdit.deleteTierError"));
}
}
- async function toggleGalleryItem(mediaId: string) {
+ // Upload a photo/video and attach it to this product's gallery in one
+ // step, so the admin never has to leave the product page or go through
+ // a separate media library screen first.
+ async function handleUploadAndAttach(e: FormEvent) {
+ e.preventDefault();
+ const file = fileInputRef.current?.files?.[0];
+ if (!file || !product) return;
+
+ setUploading(true);
try {
- if (gallery.includes(mediaId)) {
- await apiFetch(`/api/admin/products/${id}/gallery/${mediaId}`, { method: "DELETE" });
- } else {
- await apiFetch(`/api/admin/products/${id}/gallery`, {
- method: "POST",
- body: JSON.stringify({ media_id: mediaId, position: gallery.length }),
- });
- }
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("alt_text", product.name);
+ formData.append("product_id", id!);
+ const uploaded = await apiUpload("/api/admin/media", formData);
+ await apiFetch(`/api/admin/products/${id}/gallery`, {
+ method: "POST",
+ body: JSON.stringify({ media_id: uploaded.id, position: gallery.length }),
+ });
+ if (fileInputRef.current) fileInputRef.current.value = "";
load();
+ toast.success(t("common.savedToast"));
} catch {
- setMessage({ type: "error", text: "Failed to update gallery." });
+ toast.error(t("admin.productEdit.uploadError"));
+ } finally {
+ setUploading(false);
+ }
+ }
+
+ async function setPrimaryImage(mediaId: string) {
+ if (!product) return;
+ try {
+ const updated = await apiFetch(`/api/admin/products/${id}`, {
+ method: "PUT",
+ body: JSON.stringify({ ...product, primary_media_id: mediaId }),
+ });
+ setProduct(updated);
+ toast.success(t("common.savedToast"));
+ } catch {
+ toast.error(t("admin.productEdit.saveError"));
+ }
+ }
+
+ // Permanently deletes the underlying media file (not just its attachment
+ // to this product's gallery) -- there is no separate media library page
+ // anymore, so this is the only place a photo/video can be removed.
+ async function handleDeleteMedia(mediaId: string) {
+ if (!(await confirm({ message: t("admin.media.confirmDelete"), danger: true }))) return;
+ try {
+ await apiFetch(`/api/admin/media/${mediaId}`, { method: "DELETE" });
+ load();
+ toast.success(t("common.deletedToast"));
+ } catch {
+ toast.error(t("admin.media.deleteError"));
}
}
@@ -109,36 +159,32 @@ export default function ProductEditPage() {
return units.find((u) => u.id === unitId)?.symbol ?? unitId;
}
- if (loading || !product) return Loading…
;
+ if (loading || !product) return {t("common.loading")}
;
return (
- ← Back to products
+ ← {t("admin.productEdit.backLink")}
{product.name}
- {message &&
{message.text}
}
+ {loadError &&
{loadError}
}
-
Details
+
{t("admin.productEdit.detailsTitle")}
-
Quantity-based pricing
+
{t("admin.productEdit.pricingTitle")}
- | Quantity |
- Unit |
- Price |
+ {t("admin.productEdit.quantity")} |
+ {t("admin.productEdit.unit")} |
+ {t("admin.productEdit.price")} |
|
- {tiers.map((t) => (
-
- | {t.quantity} |
- {unitSymbol(t.unit_id)} |
- {formatCents(t.price_cents)} |
+ {tiers.map((tier) => (
+
+ | {tier.quantity} |
+ {unitSymbol(tier.unit_id)} |
+ {formatCents(tier.price_cents)} |
- handleDeleteTier(t.id)}>
- Delete
+ handleDeleteTier(tier.id)}>
+ {t("common.delete")}
|
@@ -220,11 +258,11 @@ export default function ProductEditPage() {
-
Gallery
-
Click a media item to attach/detach it from this product's gallery.
-
+
{t("admin.productEdit.galleryTitle")}
+
{t("admin.productEdit.galleryHint")}
+
+
+
+
{mediaLibrary.map((m) => {
- const attached = gallery.includes(m.id);
+ const isPrimary =
+ product.primary_media_id === m.id;
+
return (
toggleGalleryItem(m.id)}
style={{
- cursor: "pointer",
- border: attached ? "2px solid var(--color-primary)" : "1px solid var(--color-border)",
+ position: "relative",
+ border: isPrimary
+ ? "2px solid var(--color-primary)"
+ : "1px solid var(--color-border)",
borderRadius: 6,
padding: 4,
}}
>
{m.mime_type.startsWith("image/") ? (
-

+

) : (
-
+
{m.mime_type}
)}
+
+
setPrimaryImage(m.id)}
+ >
+ {isPrimary
+ ? t("admin.productEdit.isPrimary")
+ : t("admin.productEdit.setPrimary")}
+
+
+
handleDeleteMedia(m.id)}
+ >
+ {t("common.delete")}
+
);
})}
diff --git a/frontend/src/features/admin/ProductsPage.tsx b/frontend/src/features/admin/ProductsPage.tsx
index 323a967..437e12c 100644
--- a/frontend/src/features/admin/ProductsPage.tsx
+++ b/frontend/src/features/admin/ProductsPage.tsx
@@ -1,180 +1,814 @@
-import { useEffect, useState, type FormEvent } from "react";
+import { useEffect, useRef, useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
-import { apiFetch } from "../../api/client";
+import { apiFetch, apiUpload } from "../../api/client";
import { swapPosition } from "../../api/reorder";
-import type { Category, Product } from "../../api/types";
+import type { Category, Product, Unit, PriceTier, Media } from "../../api/types";
+import { useI18n } from "../../i18n/LanguageContext";
+import Modal from "../../ui/Modal";
+
+type NewPriceTier = {
+ quantity: string;
+ unit_id: string;
+ price: string;
+};
export default function ProductsPage() {
+ const { t } = useI18n();
+
const [products, setProducts] = useState
([]);
const [categories, setCategories] = useState([]);
+ const [units, setUnits] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ // Product fields
const [name, setName] = useState("");
- const [slug, setSlug] = useState("");
+ const [description, setDescription] = useState("");
const [categoryId, setCategoryId] = useState("");
+ const [isActive, setIsActive] = useState(true);
+ const [isFeatured, setIsFeatured] = useState(false);
+ // Creation
const [creating, setCreating] = useState(false);
+ const [createModalOpen, setCreateModalOpen] = useState(false);
+
+ // Media
+ const [mediaFile, setMediaFile] = useState(null);
+ const mediaInputRef = useRef(null);
+
+ // Price tiers
+ const [priceTiers, setPriceTiers] = useState([
+ {
+ quantity: "1",
+ unit_id: "",
+ price: "",
+ },
+ ]);
+
const navigate = useNavigate();
function load() {
setLoading(true);
+
Promise.all([
apiFetch<{ products: Product[] }>("/api/admin/products"),
apiFetch<{ categories: Category[] }>("/api/admin/categories"),
+ apiFetch<{ units: Unit[] }>("/api/admin/units"),
])
- .then(([p, c]) => {
+ .then(([p, c, u]) => {
setProducts(p.products);
setCategories(c.categories);
+ setUnits(u.units);
+
+ setCategoryId(
+ (current) => current || c.categories[0]?.id || "",
+ );
+
+ setPriceTiers((current) =>
+ current.map((tier) => ({
+ ...tier,
+ unit_id: tier.unit_id || u.units[0]?.id || "",
+ })),
+ );
})
- .catch(() => setError("Failed to load products."))
+ .catch(() => setError(t("admin.products.loadError")))
.finally(() => setLoading(false));
}
useEffect(load, []);
+ function openCreateModal() {
+ setError(null);
+
+ setName("");
+ setDescription("");
+ setCategoryId(categories[0]?.id || "");
+
+ setIsActive(true);
+ setIsFeatured(false);
+
+ setMediaFile(null);
+
+ if (mediaInputRef.current) {
+ mediaInputRef.current.value = "";
+ }
+
+ setPriceTiers([
+ {
+ quantity: "1",
+ unit_id: units[0]?.id || "",
+ price: "",
+ },
+ ]);
+
+ setCreateModalOpen(true);
+ }
+
+ function closeCreateModal() {
+ if (creating) return;
+ setCreateModalOpen(false);
+ }
+
+ function addPriceTier() {
+ setPriceTiers((current) => [
+ ...current,
+ {
+ quantity: "",
+ unit_id: units[0]?.id || "",
+ price: "",
+ },
+ ]);
+ }
+
+ function removePriceTier(index: number) {
+ setPriceTiers((current) =>
+ current.filter((_, i) => i !== index),
+ );
+ }
+
+ function updatePriceTier(
+ index: number,
+ field: keyof NewPriceTier,
+ value: string,
+ ) {
+ setPriceTiers((current) =>
+ current.map((tier, i) =>
+ i === index
+ ? {
+ ...tier,
+ [field]: value,
+ }
+ : tier,
+ ),
+ );
+ }
+
async function handleCreate(e: FormEvent) {
e.preventDefault();
+
+ if (!categoryId) {
+ setError(t("admin.products.missingCategory"));
+ return;
+ }
+
+ if (priceTiers.length > 0) {
+ const invalidTier = priceTiers.some(
+ (tier) =>
+ !tier.quantity ||
+ Number(tier.quantity) <= 0 ||
+ !tier.unit_id ||
+ !tier.price ||
+ Number(tier.price) < 0,
+ );
+
+ if (invalidTier) {
+ setError(t("admin.productEdit.addTierError"));
+ return;
+ }
+ }
+
setCreating(true);
setError(null);
+
try {
+ /*
+ * 1. Création du produit
+ */
const created = await apiFetch("/api/admin/products", {
method: "POST",
body: JSON.stringify({
name,
- slug,
- category_id: categoryId || null,
- is_active: true,
+ description,
+ category_id: categoryId,
+ is_active: isActive,
+ is_featured: isFeatured,
position: products.length,
}),
});
- navigate(`/admin/products/${created.id}`);
+
+ /*
+ * 2. Ajout des prix par quantité
+ */
+ for (let index = 0; index < priceTiers.length; index++) {
+ const tier = priceTiers[index];
+
+ await apiFetch(
+ `/api/admin/products/${created.id}/price-tiers`,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ unit_id: tier.unit_id,
+ quantity: Number(tier.quantity),
+ price_cents: Math.round(Number(tier.price) * 100),
+ position: index,
+ }),
+ },
+ );
+ }
+
+ /*
+ * 3. Upload et ajout du média
+ */
+ let updatedProduct = created;
+
+ if (mediaFile) {
+ const formData = new FormData();
+
+ formData.append("file", mediaFile);
+ formData.append("alt_text", created.name);
+ formData.append("product_id", created.id);
+
+ const uploaded = await apiUpload(
+ "/api/admin/media",
+ formData,
+ );
+
+ /*
+ * Ajout à la galerie
+ */
+ await apiFetch(
+ `/api/admin/products/${created.id}/gallery`,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ media_id: uploaded.id,
+ position: 0,
+ }),
+ },
+ );
+
+ /*
+ * Le premier média devient automatiquement
+ * l'image principale du produit.
+ */
+ updatedProduct = await apiFetch(
+ `/api/admin/products/${created.id}`,
+ {
+ method: "PUT",
+ body: JSON.stringify({
+ ...created,
+ primary_media_id: uploaded.id,
+ }),
+ },
+ );
+ }
+
+ setCreateModalOpen(false);
+
+ navigate(`/admin/products/${updatedProduct.id}`);
} catch {
- setError("Failed to create product (slug may already be in use).");
+ setError(t("admin.products.createError"));
} finally {
setCreating(false);
}
}
async function handleDelete(id: string) {
- if (!confirm("Delete this product?")) return;
+ if (!confirm(t("admin.products.confirmDelete"))) return;
+
try {
- await apiFetch(`/api/admin/products/${id}`, { method: "DELETE" });
+ await apiFetch(`/api/admin/products/${id}`, {
+ method: "DELETE",
+ });
+
load();
} catch {
- setError("Failed to delete product.");
+ setError(t("admin.products.deleteError"));
}
}
- function categoryName(id?: string | null) {
- if (!id) return "—";
- return categories.find((c) => c.id === id)?.name ?? "—";
+ function categoryName(id: string) {
+ return (
+ categories.find((c) => c.id === id)?.name ?? "—"
+ );
}
- async function handleMove(index: number, direction: "up" | "down") {
+ async function handleMove(
+ index: number,
+ direction: "up" | "down",
+ ) {
try {
- await swapPosition(products, index, direction, (id, position) =>
- apiFetch(`/api/admin/products/${id}/position`, {
- method: "PATCH",
- body: JSON.stringify({ position }),
- }),
+ await swapPosition(
+ products,
+ index,
+ direction,
+ (id, position) =>
+ apiFetch(`/api/admin/products/${id}/position`, {
+ method: "PATCH",
+ body: JSON.stringify({ position }),
+ }),
);
+
load();
} catch {
- setError("Failed to reorder products.");
+ setError(t("admin.products.reorderError"));
}
}
return (
-
Products
- {error &&
{error}
}
+
{t("admin.products.title")}
-
-
New product
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Header / bouton de création */}
+
+
+ {t("admin.products.createButton")}
+
+
+
+ {/* Aucune catégorie */}
+ {categories.length === 0 && (
+
+ {t("admin.products.noCategoriesPrefix")}{" "}
+
+ {t("admin.products.noCategoriesLink")}
+ {" "}
+ {t("admin.products.noCategoriesSuffix")}
+
+ )}
+
+ {/* =========================================================
+ MODAL CRÉATION PRODUIT
+ ========================================================= */}
+