chore: build
ci-api / test (push) Failing after 8m7s
ci-web / test (push) Failing after 5m5s

This commit is contained in:
Xor290
2026-09-20 12:18:33 +02:00
parent 8f4c7fa47a
commit 919c807004
174 changed files with 9669 additions and 1308 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.vite/
*.log
.git/
-24
View File
@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
-8
View File
@@ -1,8 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+30
View File
@@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1
# --- Build stage -------------------------------------------------------
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
# --- Runtime stage -------------------------------------------------------
# Static export served by nginx. /api and /uploads are NOT proxied here --
# in-cluster routing sends those paths straight to the backend Service (see
# charts/*/templates/ingress.yaml), the same way vite.config.ts's dev
# proxy makes /api and /uploads look same-origin to the browser. That keeps
# the refresh-token cookie same-site without any CORS config in prod.
#
# nginx-unprivileged listens on 8080 and runs as a non-root user out of the
# box (no chown/setuid dance needed to satisfy the chart's
# runAsNonRoot securityContext).
FROM nginxinc/nginx-unprivileged:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 8080
+27
View File
@@ -0,0 +1,27 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Liveness/readiness probe target -- doesn't touch the filesystem or
# fall through to the SPA fallback below.
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
# Hashed build assets (Vite fingerprints these) can be cached forever.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# BrowserRouter (see src/App.tsx) needs every unknown path to fall back
# to index.html so client-side routing can take over.
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
-6
View File
@@ -623,7 +623,6 @@
"integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
@@ -634,7 +633,6 @@
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1084,7 +1082,6 @@
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1126,7 +1123,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1136,7 +1132,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.28.0"
},
@@ -1282,7 +1277,6 @@
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
+71 -28
View File
@@ -1,50 +1,93 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { LanguageProvider } from "./i18n/LanguageContext";
import { ThemeProvider } from "./theme/ThemeContext";
import { ConfirmProvider } from "./ui/ConfirmContext";
import { ToastProvider } from "./ui/ToastContext";
import { AuthProvider } from "./features/auth/AuthContext";
import LoginPage from "./features/auth/LoginPage";
import ProtectedRoute from "./router/ProtectedRoute";
import AdminLayout from "./features/admin/AdminLayout";
import DashboardPage from "./features/admin/DashboardPage";
import SiteSettingsPage from "./features/admin/SiteSettingsPage";
import AppearanceSettingsPage from "./features/admin/AppearanceSettingsPage";
import UsersPage from "./features/admin/UsersPage";
import CategoriesPage from "./features/admin/CategoriesPage";
import UnitsPage from "./features/admin/UnitsPage";
import ProductsPage from "./features/admin/ProductsPage";
import ProductEditPage from "./features/admin/ProductEditPage";
import MediaPage from "./features/admin/MediaPage";
import OrdersPage from "./features/admin/OrdersPage";
import OrderDetailPage from "./features/admin/OrderDetailPage";
import TelegramSettingsPage from "./features/admin/TelegramSettingsPage";
import ContactLinksPage from "./features/admin/ContactLinksPage";
import { CartProvider } from "./features/storefront/CartContext";
import { CustomerAuthProvider } from "./features/storefront/CustomerAuthContext";
import StorefrontLayout from "./features/storefront/StorefrontLayout";
import HomePage from "./features/storefront/HomePage";
import ProductDetailPage from "./features/storefront/ProductDetailPage";
import CartPage from "./features/storefront/CartPage";
import CheckoutPage from "./features/storefront/CheckoutPage";
import OrderConfirmationPage from "./features/storefront/OrderConfirmationPage";
import AccountPage from "./features/storefront/AccountPage";
import ContactPage from "./features/storefront/ContactPage";
import SimpleHomePage from "./features/storefront/SimpleHomePage";
import CustomerAccountsPage from "./features/admin/CustomerAccountsPage";
import CustomerVerificationsPage from "./features/admin/CustomerVerificationsPage";
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminLayout />
</ProtectedRoute>
}
>
<Route index element={<DashboardPage />} />
<Route path="site-settings" element={<SiteSettingsPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="categories" element={<CategoriesPage />} />
<Route path="units" element={<UnitsPage />} />
<Route path="products" element={<ProductsPage />} />
<Route path="products/:id" element={<ProductEditPage />} />
<Route path="media" element={<MediaPage />} />
<Route path="orders" element={<OrdersPage />} />
<Route path="orders/:id" element={<OrderDetailPage />} />
<Route path="notifications/telegram" element={<TelegramSettingsPage />} />
</Route>
<Route path="/" element={<Navigate to="/admin" replace />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Routes>
</AuthProvider>
<ThemeProvider>
<LanguageProvider>
<ToastProvider>
<ConfirmProvider>
<AuthProvider>
<CustomerAuthProvider>
<CartProvider>
<Routes>
<Route path="/" element={<StorefrontLayout />}>
<Route index element={<HomePage />} />
<Route path="home" element={<SimpleHomePage />} />
<Route path="products/:id" element={<ProductDetailPage />} />
<Route path="cart" element={<CartPage />} />
<Route path="checkout" element={<CheckoutPage />} />
<Route path="order-confirmation" element={<OrderConfirmationPage />} />
<Route path="account" element={<AccountPage />} />
<Route path="contact" element={<ContactPage />} />
</Route>
<Route path="/login/admin" element={<LoginPage />} />
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminLayout />
</ProtectedRoute>
}
>
<Route index element={<DashboardPage />} />
<Route path="site-settings" element={<SiteSettingsPage />} />
<Route path="appearance" element={<AppearanceSettingsPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="categories" element={<CategoriesPage />} />
<Route path="units" element={<UnitsPage />} />
<Route path="products" element={<ProductsPage />} />
<Route path="products/:id" element={<ProductEditPage />} />
<Route path="orders" element={<OrdersPage />} />
<Route path="orders/:id" element={<OrderDetailPage />} />
<Route path="notifications/telegram" element={<TelegramSettingsPage />} />
<Route path="contact-links" element={<ContactLinksPage />} />
<Route path="customer-accounts" element={<CustomerAccountsPage />} />
<Route path="customer-verifications" element={<CustomerVerificationsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</CartProvider>
</CustomerAuthProvider>
</AuthProvider>
</ConfirmProvider>
</ToastProvider>
</LanguageProvider>
</ThemeProvider>
</BrowserRouter>
);
}
+102 -63
View File
@@ -1,15 +1,12 @@
// Central fetch wrapper: the access token is kept in memory only (never
// localStorage/sessionStorage, per the auth design), and a 401 triggers a
// single silent refresh-and-retry via the httpOnly refresh cookie.
let accessToken: string | null = null;
export function setAccessToken(token: string | null): void {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
//
// Admin and customer sessions are two entirely separate spaces (separate
// cookies, separate JWT audience -- see backend/internal/modules/auth) so
// each gets its own token store and its own refresh endpoint here too: a
// customer's expired access token must never be "refreshed" against the
// admin endpoint (it would just fail, since that reads the admin cookie).
export class ApiError extends Error {
status: number;
@@ -19,68 +16,110 @@ export class ApiError extends Error {
}
}
const NO_RETRY_PATHS = ["/api/auth/admin/refresh", "/api/auth/admin/login"];
async function rawFetch(path: string, init: RequestInit): Promise<Response> {
const headers = new Headers(init.headers);
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
return fetch(path, { ...init, headers, credentials: "include" });
interface AuthClient {
apiFetch<T>(path: string, init?: RequestInit): Promise<T>;
apiUpload<T>(path: string, formData: FormData): Promise<T>;
setAccessToken(token: string | null): void;
getAccessToken(): string | null;
}
let refreshInFlight: Promise<boolean> | null = null;
function createAuthClient(refreshPath: string, noRetryPaths: string[]): AuthClient {
let accessToken: string | null = null;
let refreshInFlight: Promise<boolean> | null = null;
async function refreshAccessToken(): Promise<boolean> {
if (!refreshInFlight) {
refreshInFlight = (async () => {
try {
const res = await fetch("/api/auth/admin/refresh", {
method: "POST",
credentials: "include",
});
if (!res.ok) return false;
const data = (await res.json()) as { access_token: string };
setAccessToken(data.access_token);
return true;
} catch {
return false;
} finally {
refreshInFlight = null;
async function rawFetch(path: string, init: RequestInit): Promise<Response> {
const headers = new Headers(init.headers);
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
return fetch(path, { ...init, headers, credentials: "include" });
}
async function refreshAccessToken(): Promise<boolean> {
if (!refreshInFlight) {
refreshInFlight = (async () => {
try {
const res = await fetch(refreshPath, { method: "POST", credentials: "include" });
if (!res.ok) return false;
const data = (await res.json()) as { access_token: string };
accessToken = data.access_token;
return true;
} catch {
return false;
} finally {
refreshInFlight = null;
}
})();
}
return refreshInFlight;
}
async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
let res = await rawFetch(path, init);
if (res.status === 401 && !noRetryPaths.includes(path)) {
const refreshed = await refreshAccessToken();
if (refreshed) {
res = await rawFetch(path, init);
}
})();
}
return refreshInFlight;
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
let res = await rawFetch(path, init);
if (res.status === 401 && !NO_RETRY_PATHS.includes(path)) {
const refreshed = await refreshAccessToken();
if (refreshed) {
res = await rawFetch(path, init);
}
}
if (!res.ok) {
let message = `Request failed with status ${res.status}`;
try {
const body = (await res.json()) as { error?: string };
if (body?.error) message = body.error;
} catch {
// response had no JSON body
if (!res.ok) {
let message = `Request failed with status ${res.status}`;
try {
const body = (await res.json()) as { error?: string };
if (body?.error) message = body.error;
} catch {
// response had no JSON body
}
throw new ApiError(message, res.status);
}
throw new ApiError(message, res.status);
// Some endpoints reply 201/200 with no body at all (e.g. attaching a
// gallery item just does c.Status(201)), not only 204 -- res.json() on
// an empty body throws a SyntaxError, which would otherwise surface as
// a spurious "failed to create/save" error even though the request
// actually succeeded. Treat any empty body as success with no payload.
const text = await res.text();
if (!text) {
return undefined as T;
}
return JSON.parse(text) as T;
}
if (res.status === 204) {
return undefined as T;
}
return (await res.json()) as T;
return {
apiFetch,
apiUpload: (path, formData) => apiFetch(path, { method: "POST", body: formData }),
setAccessToken: (token) => {
accessToken = token;
},
getAccessToken: () => accessToken,
};
}
export function apiUpload<T>(path: string, formData: FormData): Promise<T> {
return apiFetch<T>(path, { method: "POST", body: formData });
}
const adminClient = createAuthClient("/api/auth/admin/refresh", [
"/api/auth/admin/refresh",
"/api/auth/admin/login",
]);
const customerClient = createAuthClient("/api/auth/customer/refresh", [
"/api/auth/customer/refresh",
"/api/auth/customer/login",
"/api/auth/customer/register",
]);
// Default export used throughout the admin panel (unchanged call sites).
export const apiFetch = adminClient.apiFetch;
export const apiUpload = adminClient.apiUpload;
export const setAccessToken = adminClient.setAccessToken;
export const getAccessToken = adminClient.getAccessToken;
// Customer-space equivalents, used by the storefront's customer auth/account
// pages. Public storefront reads (catalog, site settings, ...) need no
// token at all and can use either client -- they use the admin one above by
// convention since it's already imported everywhere.
export const customerApiFetch = customerClient.apiFetch;
export const customerApiUpload = customerClient.apiUpload;
export const setCustomerAccessToken = customerClient.setAccessToken;
export const getCustomerAccessToken = customerClient.getAccessToken;
+55 -16
View File
@@ -1,6 +1,6 @@
export interface User {
id: string;
email: string;
username: string;
role: string;
is_active?: boolean;
}
@@ -8,16 +8,44 @@ export interface User {
export interface SiteSettings {
name: string;
description: string;
slug: string;
orders_enabled: boolean;
header_bg_color: string;
header_text_color: string;
body_bg_color: string;
body_text_color: string;
footer_bg_color: string;
footer_text_color: string;
accent_color: string;
product_layout: "grid" | "list" | "grid-overlay" | "grid-minimal";
product_columns: number;
product_scroll: "vertical" | "horizontal";
contact_card_transparent: boolean;
contact_card_bg_color: string;
logo_media_id: string | null;
hero_media_id: string | null;
hero_pages: string[];
customer_login_enabled: boolean;
customer_registration_enabled: boolean;
customer_verification_required: boolean;
verification_contact_link_id: string | null;
}
export const PRODUCT_LAYOUTS = ["grid", "grid-overlay", "grid-minimal", "list"] as const;
export const PRODUCT_COLUMNS = [0, 2, 3, 4, 5] as const;
export const PRODUCT_SCROLL_DIRECTIONS = ["vertical", "horizontal"] as const;
export const HERO_PAGES = ["catalog", "home", "cart", "checkout", "account", "contact"] as const;
export interface Category {
id: string;
name: string;
slug: string;
description: string;
position: number;
is_active: boolean;
media_id: string | null;
}
export interface Unit {
@@ -33,14 +61,15 @@ export interface Media {
mime_type: string;
size_bytes: number;
alt_text: string;
product_id: string;
created_at: string;
updated_at: string;
}
export interface Product {
id: string;
category_id?: string | null;
category_id: string;
name: string;
slug: string;
short_description: string;
description: string;
is_active: boolean;
is_featured: boolean;
@@ -57,6 +86,26 @@ export interface PriceTier {
position: number;
}
export interface ContactLink {
id: string;
label: string;
url: string;
icon_key: string;
icon_media_id: string | null;
color: string;
position: number;
is_active: boolean;
}
export interface CustomerVerification {
id: string;
user_id: string;
status: "pending" | "approved" | "rejected";
admin_note: string;
created_at: string;
updated_at: string;
}
export interface TelegramSettings {
enabled: boolean;
bot_token_configured: boolean;
@@ -80,21 +129,11 @@ export interface Order {
customer_name: string;
customer_email: string;
customer_phone: string;
status: string;
total_cents: number;
notes: string;
items?: OrderItem[];
}
export const ORDER_STATUSES = [
"pending",
"confirmed",
"preparing",
"shipped",
"completed",
"cancelled",
] as const;
export function formatCents(cents: number): string {
return (cents / 100).toFixed(2);
}
+90 -35
View File
@@ -1,75 +1,122 @@
import { useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../../i18n/LanguageContext";
import LanguageSwitcher from "../../i18n/LanguageSwitcher";
import ThemeToggle from "../../theme/ThemeToggle";
import type { MessageKey } from "../../i18n/messages";
import { LogOutIcon } from "../../ui/icons";
const NAV_SECTIONS: { title: string; items: { to: string; label: string }[] }[] = [
import { AdminSiteSettingsProvider, useAdminSiteSettings } from "./AdminSiteSettingsContext";
const NAV_SECTIONS: { titleKey: MessageKey; items: { to: string; labelKey: MessageKey }[] }[] = [
{
title: "Site",
titleKey: "layout.admin.nav.site",
items: [
{ to: "/admin", label: "Dashboard" },
{ to: "/admin/site-settings", label: "General" },
{ to: "/admin", labelKey: "layout.admin.nav.dashboard" },
{ to: "/admin/site-settings", labelKey: "layout.admin.nav.general" },
{ to: "/admin/appearance", labelKey: "layout.admin.nav.appearance" },
],
},
{
title: "Catalog",
titleKey: "layout.admin.nav.catalog",
items: [
{ to: "/admin/categories", label: "Categories" },
{ to: "/admin/units", label: "Units" },
{ to: "/admin/products", label: "Products" },
{ to: "/admin/media", label: "Media" },
{ to: "/admin/categories", labelKey: "layout.admin.nav.categories" },
{ to: "/admin/units", labelKey: "layout.admin.nav.units" },
{ to: "/admin/products", labelKey: "layout.admin.nav.products" },
],
},
{
title: "Sales",
items: [{ to: "/admin/orders", label: "Orders" }],
titleKey: "layout.admin.nav.sales",
items: [{ to: "/admin/orders", labelKey: "layout.admin.nav.orders" }],
},
{
title: "Communication",
items: [{ to: "/admin/notifications/telegram", label: "Telegram" }],
titleKey: "layout.admin.nav.communication",
items: [
{ to: "/admin/notifications/telegram", labelKey: "layout.admin.nav.telegram" },
{ to: "/admin/contact-links", labelKey: "layout.admin.nav.contactLinks" },
],
},
{
title: "System",
items: [{ to: "/admin/users", label: "Users" }],
titleKey: "layout.admin.nav.system",
items: [{ to: "/admin/users", labelKey: "layout.admin.nav.users" }, { to: "/admin/customer-accounts", labelKey: "layout.admin.nav.customerAccounts" }],
},
];
export default function AdminLayout() {
function AdminShell() {
const { user, logout } = useAuth();
const { t } = useI18n();
const { settings } = useAdminSiteSettings();
const navigate = useNavigate();
const [sidebarOpen, setSidebarOpen] = useState(false);
async function handleLogout() {
await logout();
navigate("/login", { replace: true });
}
// "Customer verifications" only makes sense -- and is only shown -- once
// the admin has actually turned on identity verification in Customer
// accounts; otherwise there is nothing to review there.
const navSections = settings.customer_verification_required
? NAV_SECTIONS.map((section) =>
section.titleKey === "layout.admin.nav.system"
? {
...section,
items: [...section.items, { to: "/admin/customer-verifications", labelKey: "layout.admin.nav.customerVerifications" as MessageKey }],
}
: section,
)
: NAV_SECTIONS;
return (
<div className="admin-shell">
<aside className="admin-sidebar">
<h2>Admin Panel</h2>
<nav>
{NAV_SECTIONS.map((section) => (
<div key={section.title} style={{ marginBottom: "0.75rem" }}>
<aside className={`admin-sidebar ${sidebarOpen ? "open" : ""}`}>
<h2>{t("layout.admin.title")}</h2>
<nav onClick={() => setSidebarOpen(false)}>
{navSections.map((section) => (
<div key={section.titleKey} style={{ marginBottom: "0.75rem" }}>
<div style={{ fontSize: "0.7rem", opacity: 0.6, padding: "0 0.6rem", marginBottom: "0.2rem" }}>
{section.title.toUpperCase()}
{t(section.titleKey).toUpperCase()}
</div>
<div className="admin-nav-items">
{section.items.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === "/admin"}
className={({ isActive }) => (isActive ? "active" : "")}
>
{t(item.labelKey)}
</NavLink>
))}
</div>
{section.items.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === "/admin"}
className={({ isActive }) => (isActive ? "active" : "")}
>
{item.label}
</NavLink>
))}
</div>
))}
</nav>
</aside>
{sidebarOpen && <div className="admin-sidebar-backdrop" onClick={() => setSidebarOpen(false)} />}
<div className="admin-main">
<header className="admin-topbar">
<span>{user?.email}</span>
<button className="btn" onClick={handleLogout}>
Log out
<button
type="button"
className="btn admin-menu-toggle"
aria-label={t("layout.admin.toggleMenu")}
onClick={() => setSidebarOpen((open) => !open)}
>
</button>
<span>{user?.username}</span>
<LanguageSwitcher />
<ThemeToggle />
<button
type="button"
className="btn admin-logout-btn"
onClick={handleLogout}
title={t("common.logout")}
aria-label={t("common.logout")}
>
<LogOutIcon />
</button>
</header>
<main className="admin-content">
@@ -79,3 +126,11 @@ export default function AdminLayout() {
</div>
);
}
export default function AdminLayout() {
return (
<AdminSiteSettingsProvider>
<AdminShell />
</AdminSiteSettingsProvider>
);
}
@@ -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<AdminSiteSettingsContextValue | undefined>(undefined);
export function AdminSiteSettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState<SiteSettings>(DEFAULT_SETTINGS);
const [loading, setLoading] = useState(true);
function refresh() {
apiFetch<SiteSettings>("/api/admin/site-settings")
.then(setSettings)
.catch(() => {})
.finally(() => setLoading(false));
}
useEffect(refresh, []);
return (
<AdminSiteSettingsContext.Provider value={{ settings, loading, refresh }}>{children}</AdminSiteSettingsContext.Provider>
);
}
export function useAdminSiteSettings(): AdminSiteSettingsContextValue {
const ctx = useContext(AdminSiteSettingsContext);
if (!ctx) throw new Error("useAdminSiteSettings must be used within an AdminSiteSettingsProvider");
return ctx;
}
@@ -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<SiteSettings | null>(null);
const [mediaLibrary, setMediaLibrary] = useState<Media[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const uploadTargetRef = useRef<"logo" | "hero" | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
function load() {
setLoading(true);
Promise.all([apiFetch<SiteSettings>("/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<SiteSettings>("/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<HTMLInputElement>) {
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<Media>("/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 <div className="page-loading">{t("common.loading")}</div>;
function colorField(key: keyof SiteSettings, label: string) {
const value = settings![key] as string;
return (
<div className="field">
<label htmlFor={key}>{label}</label>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<input
id={key}
type="color"
value={value}
onChange={(e) => setSettings({ ...settings!, [key]: e.target.value })}
/>
<code style={{ color: "var(--color-text-muted)", fontSize: "0.85rem" }}>{value}</code>
</div>
</div>
);
}
function imagePicker(target: "logo" | "hero", currentId: string | null, onSelect: (id: string | null) => void) {
return (
<div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(90px, 1fr))", gap: "0.5rem", marginBottom: "0.75rem" }}>
<div
onClick={() => 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")}
</div>
{mediaLibrary
.filter((m) => m.mime_type.startsWith("image/"))
.map((m) => (
<div
key={m.id}
onClick={() => onSelect(m.id)}
style={{
cursor: "pointer",
border: currentId === m.id ? "2px solid var(--color-primary)" : "1px solid var(--color-border)",
borderRadius: 6,
padding: 4,
}}
>
<img src={m.url} alt={m.alt_text} style={{ width: "100%", height: 52, objectFit: "cover", borderRadius: 4 }} />
</div>
))}
</div>
<button type="button" className="btn" onClick={() => triggerUpload(target)} disabled={uploading}>
{uploading ? t("common.uploading") : t("admin.appearance.uploadImage")}
</button>
</div>
);
}
return (
<div>
<h1>{t("admin.appearance.title")}</h1>
<div className="panel">
{loadError && <div className="alert alert-error">{loadError}</div>}
<input type="file" accept="image/*" ref={fileInputRef} onChange={handleFileSelected} style={{ display: "none" }} />
<form onSubmit={handleSubmit}>
<AccordionSection title={t("admin.appearance.header")} defaultOpen>
<div className="form-row">
{colorField("header_bg_color", t("admin.appearance.background"))}
{colorField("header_text_color", t("admin.appearance.text"))}
</div>
</AccordionSection>
<AccordionSection title={t("admin.appearance.logo")}>
<p style={{ color: "var(--color-text-muted)", fontSize: "0.85rem" }}>{t("admin.appearance.logoHint")}</p>
{imagePicker("logo", settings.logo_media_id, (id) => setSettings({ ...settings, logo_media_id: id }))}
</AccordionSection>
<AccordionSection title={t("admin.appearance.body")}>
<div className="form-row">
{colorField("body_bg_color", t("admin.appearance.background"))}
{colorField("body_text_color", t("admin.appearance.text"))}
</div>
</AccordionSection>
<AccordionSection title={t("admin.appearance.footer")}>
<div className="form-row">
{colorField("footer_bg_color", t("admin.appearance.background"))}
{colorField("footer_text_color", t("admin.appearance.text"))}
</div>
</AccordionSection>
<AccordionSection title={t("admin.appearance.accent")}>
<div className="form-row">{colorField("accent_color", t("admin.appearance.buttonsLinks"))}</div>
</AccordionSection>
<AccordionSection title={t("admin.appearance.contactCards")}>
<p style={{ color: "var(--color-text-muted)", fontSize: "0.85rem" }}>{t("admin.appearance.contactCardsHint")}</p>
<div className="field field-inline">
<input
id="contact_card_transparent"
type="checkbox"
checked={settings.contact_card_transparent}
onChange={(e) => setSettings({ ...settings, contact_card_transparent: e.target.checked })}
/>
<label htmlFor="contact_card_transparent">{t("admin.appearance.contactCardsTransparent")}</label>
</div>
{!settings.contact_card_transparent && (
<div className="form-row">{colorField("contact_card_bg_color", t("admin.appearance.background"))}</div>
)}
</AccordionSection>
<AccordionSection title={t("admin.appearance.hero")}>
<p style={{ color: "var(--color-text-muted)", fontSize: "0.85rem" }}>{t("admin.appearance.heroHint")}</p>
{imagePicker("hero", settings.hero_media_id, (id) => setSettings({ ...settings, hero_media_id: id }))}
{settings.hero_media_id && (
<div className="field" style={{ marginTop: "1rem" }}>
<label>{t("admin.appearance.heroPages")}</label>
<div style={{ display: "flex", flexWrap: "wrap", gap: "1rem" }}>
{HERO_PAGES.map((page) => (
<div className="field-inline" key={page}>
<input
id={`hero-page-${page}`}
type="checkbox"
checked={settings.hero_pages.includes(page)}
onChange={() => toggleHeroPage(page)}
/>
<label htmlFor={`hero-page-${page}`}>{t(HERO_PAGE_KEYS[page])}</label>
</div>
))}
</div>
</div>
)}
</AccordionSection>
<AccordionSection title={t("admin.appearance.layout")}>
<div className="form-row">
<div className="field">
<label htmlFor="product_layout">{t("admin.appearance.productDisplay")}</label>
<select
id="product_layout"
value={settings.product_layout}
onChange={(e) => setSettings({ ...settings, product_layout: e.target.value as SiteSettings["product_layout"] })}
>
{PRODUCT_LAYOUTS.map((layout) => (
<option key={layout} value={layout}>
{t(PRODUCT_LAYOUT_KEYS[layout])}
</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="product_scroll">{t("admin.appearance.scrollDirection")}</label>
<select
id="product_scroll"
value={settings.product_scroll}
onChange={(e) => setSettings({ ...settings, product_scroll: e.target.value as SiteSettings["product_scroll"] })}
>
{PRODUCT_SCROLL_DIRECTIONS.map((direction) => (
<option key={direction} value={direction}>
{t(PRODUCT_SCROLL_KEYS[direction])}
</option>
))}
</select>
</div>
{settings.product_layout !== "list" && settings.product_scroll !== "horizontal" && (
<div className="field">
<label htmlFor="product_columns">{t("admin.appearance.columnsPerRow")}</label>
<select
id="product_columns"
value={settings.product_columns}
onChange={(e) => setSettings({ ...settings, product_columns: Number(e.target.value) })}
>
{PRODUCT_COLUMNS.map((n) => (
<option key={n} value={n}>
{n === 0 ? t("admin.appearance.columnsAuto") : n}
</option>
))}
</select>
</div>
)}
</div>
</AccordionSection>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? t("common.saving") : t("common.save")}
</button>
</div>
</form>
</div>
</div>
);
}
+180 -80
View File
@@ -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<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [mediaLibrary, setMediaLibrary] = useState<Media[]>([]);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
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<Media>("/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 (
<div>
<h1>Categories</h1>
<h1>{t("admin.categories.title")}</h1>
{error && <div className="alert alert-error">{error}</div>}
<div className="panel">
<h2>{editingId ? "Edit category" : "New category"}</h2>
<form onSubmit={handleSubmit}>
<div className="form-row">
<div className="field">
<label htmlFor="cat-name">Name</label>
<input id="cat-name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</div>
<div className="field">
<label htmlFor="cat-slug">Slug</label>
<input id="cat-slug" value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} required />
</div>
<div className="field">
<label htmlFor="cat-position">Position</label>
<input
id="cat-position"
type="number"
value={form.position}
onChange={(e) => setForm({ ...form, position: Number(e.target.value) })}
/>
</div>
</div>
<div className="field">
<label htmlFor="cat-description">Description</label>
<textarea
id="cat-description"
rows={2}
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
<div className="field field-inline">
<input
id="cat-active"
type="checkbox"
checked={form.is_active}
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
/>
<label htmlFor="cat-active">Active</label>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? "Saving…" : editingId ? "Save changes" : "Create category"}
</button>
{editingId && (
<button type="button" className="btn" onClick={cancelEdit}>
Cancel
</button>
)}
</div>
</form>
<div
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
marginBottom: "1rem",
}}
>
<button type="button" className="btn btn-primary" onClick={openCreateModal}>
{t("admin.categories.createButton")}
</button>
</div>
<Modal open={modalOpen} onClose={closeModal} labelledBy={editingId ? t("admin.categories.editTitle") : t("admin.categories.newTitle")}>
<div className="panel">
<h3>{editingId ? t("admin.categories.editTitle") : t("admin.categories.newTitle")}</h3>
<form onSubmit={handleSubmit}>
<div className="form-row">
<div className="field">
<label htmlFor="cat-name">{t("admin.categories.name")}</label>
<input id="cat-name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required autoFocus />
</div>
<div className="field">
<label htmlFor="cat-position">{t("admin.categories.position")}</label>
<input
id="cat-position"
type="number"
value={form.position}
onChange={(e) => setForm({ ...form, position: Number(e.target.value) })}
/>
</div>
</div>
<div className="field">
<label htmlFor="cat-description">{t("admin.categories.description")}</label>
<textarea
id="cat-description"
rows={2}
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
/>
</div>
<div className="field field-inline">
<input
id="cat-active"
type="checkbox"
checked={form.is_active}
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
/>
<label htmlFor="cat-active">{t("admin.categories.active")}</label>
</div>
<div className="field">
<label>{t("admin.categories.image")}</label>
<input type="file" accept="image/*" ref={fileInputRef} onChange={handleFileSelected} style={{ display: "none" }} />
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(90px, 1fr))", gap: "0.5rem", marginBottom: "0.75rem" }}>
<div
onClick={() => setForm({ ...form, media_id: null })}
style={{
cursor: "pointer",
border: form.media_id === 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")}
</div>
{mediaLibrary
.filter((m) => m.mime_type.startsWith("image/"))
.map((m) => (
<div
key={m.id}
onClick={() => setForm({ ...form, media_id: m.id })}
style={{
cursor: "pointer",
border: form.media_id === m.id ? "2px solid var(--color-primary)" : "1px solid var(--color-border)",
borderRadius: 6,
padding: 4,
}}
>
<img src={m.url} alt={m.alt_text} style={{ width: "100%", height: 52, objectFit: "cover", borderRadius: 4 }} />
</div>
))}
</div>
<button type="button" className="btn" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
{uploading ? t("common.uploading") : t("admin.appearance.uploadImage")}
</button>
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={closeModal} disabled={saving}>
{t("common.cancel")}
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? t("common.saving") : editingId ? t("admin.categories.saveChanges") : t("admin.categories.createButton")}
</button>
</div>
</form>
</div>
</Modal>
{loading ? (
<div className="page-loading">Loading</div>
<div className="page-loading">{t("common.loading")}</div>
) : (
<table>
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Position</th>
<th>Status</th>
<th>{t("admin.categories.name")}</th>
<th>{t("admin.categories.position")}</th>
<th>{t("admin.categories.colStatus")}</th>
<th></th>
</tr>
</thead>
@@ -157,13 +258,12 @@ export default function CategoriesPage() {
{categories.map((cat, index) => (
<tr key={cat.id}>
<td>{cat.name}</td>
<td>{cat.slug}</td>
<td>
<div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<button
type="button"
className="btn"
title="Move up"
title={t("common.moveUp")}
disabled={index === 0}
onClick={() => handleMove(index, "up")}
>
@@ -172,7 +272,7 @@ export default function CategoriesPage() {
<button
type="button"
className="btn"
title="Move down"
title={t("common.moveDown")}
disabled={index === categories.length - 1}
onClick={() => handleMove(index, "down")}
>
@@ -183,15 +283,15 @@ export default function CategoriesPage() {
</td>
<td>
<span className={`badge ${cat.is_active ? "badge-active" : "badge-inactive"}`}>
{cat.is_active ? "active" : "inactive"}
{cat.is_active ? t("common.active") : t("common.inactive")}
</span>
</td>
<td>
<button className="btn" onClick={() => startEdit(cat)}>
Edit
<button className="btn" onClick={() => openEditModal(cat)}>
{t("common.edit")}
</button>{" "}
<button className="btn btn-danger" onClick={() => handleDelete(cat.id)}>
Delete
{t("common.delete")}
</button>
</td>
</tr>
@@ -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<ContactLink[]>([]);
const [mediaLibrary, setMediaLibrary] = useState<Media[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [uploadingIcon, setUploadingIcon] = useState(false);
const iconFileInputRef = useRef<HTMLInputElement>(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<Media>("/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 (
<span
style={{
width: 28,
height: 28,
borderRadius: "50%",
background: link.color || brandIcon.defaultColor,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
}}
>
<SocialIconGlyph path={brandIcon.path} className="social-icon-sm" />
</span>
);
}
if (mediaUrl) {
return <img src={mediaUrl} alt={link.label} style={{ width: 28, height: 28, objectFit: "cover", borderRadius: 4 }} />;
}
return "—";
}
return (
<div>
<h1>{t("admin.contactLinks.title")}</h1>
{error && <div className="alert alert-error">{error}</div>}
<div className="panel">
<h2>{editingId ? t("admin.contactLinks.editTitle") : t("admin.contactLinks.newTitle")}</h2>
<form onSubmit={handleSubmit}>
<div className="form-row">
<div className="field">
<label htmlFor="link-label">{t("admin.contactLinks.label")}</label>
<input
id="link-label"
value={form.label}
onChange={(e) => setForm({ ...form, label: e.target.value })}
placeholder="WhatsApp"
required
/>
</div>
<div className="field">
<label htmlFor="link-url">{t("admin.contactLinks.url")}</label>
<input
id="link-url"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
placeholder="https://wa.me/33600000000"
required
/>
</div>
<div className="field">
<label htmlFor="link-position">{t("admin.contactLinks.position")}</label>
<input
id="link-position"
type="number"
value={form.position}
onChange={(e) => setForm({ ...form, position: Number(e.target.value) })}
/>
</div>
<div className="field">
<label htmlFor="link-color">{t("admin.contactLinks.color")}</label>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<input
id="link-color"
type="color"
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
/>
<code style={{ color: "var(--color-text-muted)", fontSize: "0.85rem" }}>{form.color}</code>
</div>
</div>
</div>
<div className="field">
<label>{t("admin.contactLinks.brandIcon")}</label>
<div className="social-icon-picker">
{SOCIAL_ICONS.map((icon) => (
<button
type="button"
key={icon.key}
title={icon.label}
onClick={() => pickBrandIcon(icon.key)}
className={`social-icon-option ${form.icon_key === icon.key ? "selected" : ""}`}
style={{ background: icon.defaultColor }}
>
<SocialIconGlyph path={icon.path} className="social-icon-sm" />
</button>
))}
</div>
</div>
<div className="field">
<label htmlFor="link-icon-upload">{t("admin.contactLinks.customIcon")}</label>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem" }}>
<input id="link-icon-upload" type="file" accept="image/*" ref={iconFileInputRef} />
<button type="button" className="btn" onClick={handleUploadIcon} disabled={uploadingIcon}>
{uploadingIcon ? t("common.uploading") : t("admin.contactLinks.uploadIcon")}
</button>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(80px, 1fr))", gap: "0.5rem" }}>
{mediaLibrary
.filter((m) => m.mime_type.startsWith("image/"))
.map((m) => (
<div
key={m.id}
style={{
border: form.icon_media_id === m.id ? "2px solid var(--color-primary)" : "1px solid var(--color-border)",
borderRadius: 6,
padding: 4,
}}
>
<img
src={m.url}
alt={m.alt_text}
onClick={() => pickCustomIcon(m.id)}
style={{ width: "100%", height: 52, objectFit: "cover", borderRadius: 4, cursor: "pointer" }}
/>
<button
type="button"
className="btn btn-danger"
style={{ width: "100%", marginTop: 4, fontSize: "0.65rem", padding: "0.15rem" }}
onClick={() => handleDeleteIcon(m.id)}
>
{t("common.delete")}
</button>
</div>
))}
</div>
</div>
<div className="field field-inline">
<input
id="link-active"
type="checkbox"
checked={form.is_active}
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
/>
<label htmlFor="link-active">{t("admin.contactLinks.active")}</label>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? t("common.saving") : editingId ? t("admin.contactLinks.saveChanges") : t("admin.contactLinks.createButton")}
</button>
{editingId && (
<button type="button" className="btn" onClick={cancelEdit}>
{t("common.cancel")}
</button>
)}
</div>
</form>
</div>
{loading ? (
<div className="page-loading">{t("common.loading")}</div>
) : (
<table>
<thead>
<tr>
<th>{t("admin.contactLinks.colIcon")}</th>
<th>{t("admin.contactLinks.colLabel")}</th>
<th>{t("admin.contactLinks.colUrl")}</th>
<th>{t("admin.contactLinks.colColor")}</th>
<th>{t("admin.contactLinks.colPosition")}</th>
<th>{t("admin.contactLinks.colStatus")}</th>
<th></th>
</tr>
</thead>
<tbody>
{links.map((link, index) => (
<tr key={link.id}>
<td>{renderPreviewIcon(link)}</td>
<td style={{ color: link.color || undefined }}>{link.label}</td>
<td>{link.url}</td>
<td>
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.4rem" }}>
<span style={{ width: 14, height: 14, borderRadius: "50%", background: link.color || "transparent", border: "1px solid var(--color-border)", display: "inline-block" }} />
{link.color}
</span>
</td>
<td>
<div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
<button
type="button"
className="btn"
title={t("common.moveUp")}
disabled={index === 0}
onClick={() => handleMove(index, "up")}
>
</button>
<button
type="button"
className="btn"
title={t("common.moveDown")}
disabled={index === links.length - 1}
onClick={() => handleMove(index, "down")}
>
</button>
<span>{link.position}</span>
</div>
</td>
<td>
<span className={`badge ${link.is_active ? "badge-active" : "badge-inactive"}`}>
{link.is_active ? t("common.active") : t("common.inactive")}
</span>
</td>
<td>
<button className="btn" onClick={() => startEdit(link)}>
{t("common.edit")}
</button>{" "}
<button className="btn btn-danger" onClick={() => handleDelete(link.id)}>
{t("common.delete")}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
@@ -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<SiteSettings | null>(null);
const [contactLinks, setContactLinks] = useState<ContactLink[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
function load() {
setLoading(true);
Promise.all([
apiFetch<SiteSettings>("/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<SiteSettings>("/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 <div className="page-loading">{t("common.loading")}</div>;
return (
<div>
<h1>{t("admin.customerAccounts.title")}</h1>
{loadError && <div className="alert alert-error">{loadError}</div>}
<div className="panel">
<form onSubmit={handleSubmit}>
<div className="field field-inline">
<input
id="login-enabled"
type="checkbox"
checked={settings.customer_login_enabled}
onChange={(e) => setSettings({ ...settings, customer_login_enabled: e.target.checked })}
/>
<label htmlFor="login-enabled">{t("admin.customerAccounts.loginEnabled")}</label>
</div>
<div className="field field-inline">
<input
id="registration-enabled"
type="checkbox"
checked={settings.customer_registration_enabled}
onChange={(e) => setSettings({ ...settings, customer_registration_enabled: e.target.checked })}
/>
<label htmlFor="registration-enabled">{t("admin.customerAccounts.registrationEnabled")}</label>
</div>
<div className="field field-inline">
<input
id="verification-required"
type="checkbox"
checked={settings.customer_verification_required}
onChange={(e) => setSettings({ ...settings, customer_verification_required: e.target.checked })}
/>
<label htmlFor="verification-required">{t("admin.customerAccounts.verificationRequired")}</label>
</div>
<div className="field">
<label htmlFor="verification-contact">{t("admin.customerAccounts.contactLabel")}</label>
<select
id="verification-contact"
value={settings.verification_contact_link_id ?? ""}
onChange={(e) =>
setSettings({ ...settings, verification_contact_link_id: e.target.value || null })
}
>
<option value="">{t("admin.customerAccounts.none")}</option>
{contactLinks.map((link) => (
<option key={link.id} value={link.id}>
{link.label}
</option>
))}
</select>
{contactLinks.length === 0 && (
<p style={{ color: "var(--color-text-muted)", fontSize: "0.85rem" }}>
{t("admin.customerAccounts.noContactLinksPrefix")}{" "}
<Link to="/admin/contact-links">{t("admin.customerAccounts.noContactLinksLink")}</Link>.
</p>
)}
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? t("common.saving") : t("common.save")}
</button>
</div>
</form>
</div>
{settings.customer_verification_required && (
<div className="panel">
<p>
{t("admin.customerAccounts.reviewHint")}{" "}
<Link to="/admin/customer-verifications">{t("admin.customerAccounts.reviewLink")}</Link>.
</p>
</div>
)}
</div>
);
}
@@ -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<CustomerVerification["status"], MessageKey> = {
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 <img> to display -- a plain <img src="/api/..."> would send
// no Authorization header and get a 401.
function useAdminDocumentUrl(verificationId: string, side: "front" | "back"): string | undefined {
const [url, setUrl] = useState<string | undefined>(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 (
<div>
<p style={{ fontSize: "0.8rem", color: "var(--color-text-muted)" }}>
{side === "front" ? t("admin.customerVerifications.front") : t("admin.customerVerifications.back")}
</p>
{url ? (
<img src={url} alt={`${side} document`} style={{ width: "100%", maxWidth: 320, borderRadius: 4 }} />
) : (
<div className="page-loading">{t("common.loading")}</div>
)}
</div>
);
}
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 (
<div className="panel">
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
<DocumentImage verificationId={verification.id} side="front" />
<DocumentImage verificationId={verification.id} side="back" />
</div>
<div className="field" style={{ marginTop: "1rem" }}>
<label htmlFor="admin-note">{t("admin.customerVerifications.adminNote")}</label>
<textarea id="admin-note" rows={2} value={note} onChange={(e) => setNote(e.target.value)} />
</div>
<div className="form-actions">
<button className="btn btn-primary" disabled={submitting} onClick={() => review("approved")}>
{t("admin.customerVerifications.approve")}
</button>
<button className="btn btn-danger" disabled={submitting} onClick={() => review("rejected")}>
{t("admin.customerVerifications.reject")}
</button>
</div>
</div>
);
}
export default function CustomerVerificationsPage() {
const { t } = useI18n();
const { settings } = useAdminSiteSettings();
const [status, setStatus] = useState<string>("pending");
const [list, setList] = useState<CustomerVerification[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div>
<h1>{t("admin.customerVerifications.title")}</h1>
<p>{t("admin.customerVerifications.disabledHint")}</p>
</div>
);
}
return (
<div>
<h1>{t("admin.customerVerifications.title")}</h1>
{error && <div className="alert alert-error">{error}</div>}
<div className="toolbar">
<select value={status} onChange={(e) => setStatus(e.target.value)} style={{ maxWidth: 200 }}>
<option value="">{t("admin.customerVerifications.allStatuses")}</option>
{STATUSES.map((s) => (
<option key={s} value={s}>
{t(STATUS_KEYS[s])}
</option>
))}
</select>
</div>
{loading ? (
<div className="page-loading">{t("common.loading")}</div>
) : list.length === 0 ? (
<p>{t("admin.customerVerifications.none")}</p>
) : (
<table>
<thead>
<tr>
<th>{t("admin.customerVerifications.colUserId")}</th>
<th>{t("admin.customerVerifications.colStatus")}</th>
<th>{t("admin.customerVerifications.colSubmitted")}</th>
<th></th>
</tr>
</thead>
<tbody>
{list.map((v) => (
<tr key={v.id}>
<td>{v.user_id}</td>
<td>
<span className="badge">{t(STATUS_KEYS[v.status])}</span>
</td>
<td>{new Date(v.created_at).toLocaleString()}</td>
<td>
<button className="btn" onClick={() => setSelectedId(v.id)}>
{t("admin.customerVerifications.review")}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{selected && (
<ReviewPanel
key={selected.id}
verification={selected}
onReviewed={() => {
setSelectedId(null);
load();
}}
/>
)}
</div>
);
}
+61 -8
View File
@@ -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 (
<div className="panel" style={{ marginBottom: 0, textAlign: "center" }}>
<p style={{ margin: 0, fontSize: "2rem", fontWeight: 700 }}>{value === null ? "—" : value}</p>
<p style={{ margin: 0, color: "var(--color-text-muted)" }}>{label}</p>
</div>
);
}
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<number | null>(null);
const [productCount, setProductCount] = useState<number | null>(null);
const [customerCount, setCustomerCount] = useState<number | null>(null);
const [orderCount, setOrderCount] = useState<number | null>(null);
const [error, setError] = useState<string | null>(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 (
<div>
<h1>Dashboard</h1>
<div className="panel">
<p>
Signed in as <strong>{user?.email}</strong> ({user?.role}).
</p>
<p>Use the sidebar to configure the site: identity, catalog, orders and notifications.</p>
<h1>{t("admin.dashboard.title")}</h1>
{error && <div className="alert alert-error">{error}</div>}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
gap: "1rem",
}}
>
<StatCard label={t("admin.dashboard.categoryCount")} value={categoryCount} />
<StatCard label={t("admin.dashboard.productCount")} value={productCount} />
{customerAccountsEnabled && <StatCard label={t("admin.dashboard.customerCount")} value={customerCount} />}
{settings.orders_enabled && <StatCard label={t("admin.dashboard.orderCount")} value={orderCount} />}
</div>
</div>
);
-102
View File
@@ -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<Media[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [altText, setAltText] = useState("");
const fileInputRef = useRef<HTMLInputElement>(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 (
<div>
<h1>Media</h1>
{error && <div className="alert alert-error">{error}</div>}
<div className="panel">
<h2>Upload</h2>
<form onSubmit={handleUpload}>
<div className="field">
<label htmlFor="file">File (image or video)</label>
<input id="file" type="file" ref={fileInputRef} accept="image/*,video/mp4,video/webm" required />
</div>
<div className="field">
<label htmlFor="alt-text">Alt text</label>
<input id="alt-text" value={altText} onChange={(e) => setAltText(e.target.value)} />
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={uploading}>
{uploading ? "Uploading…" : "Upload"}
</button>
</div>
</form>
</div>
{loading ? (
<div className="page-loading">Loading</div>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))", gap: "1rem" }}>
{media.map((m) => (
<div key={m.id} className="panel" style={{ padding: "0.75rem", marginBottom: 0 }}>
{m.mime_type.startsWith("image/") ? (
<img src={m.url} alt={m.alt_text} style={{ width: "100%", height: 100, objectFit: "cover", borderRadius: 4 }} />
) : (
<div style={{ height: 100, display: "flex", alignItems: "center", justifyContent: "center", background: "#f3f4f6" }}>
{m.mime_type}
</div>
)}
<p style={{ fontSize: "0.75rem", margin: "0.4rem 0", wordBreak: "break-all" }}>{m.filename}</p>
<button className="btn btn-danger" style={{ width: "100%" }} onClick={() => handleDelete(m.id)}>
Delete
</button>
</div>
))}
</div>
)}
</div>
);
}
+23 -45
View File
@@ -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<Order | null>(null);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
function load() {
setLoading(true);
apiFetch<Order>(`/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<Order>(`/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 <div className="page-loading">Loading</div>;
if (!order) return <div className="alert alert-error">Order not found.</div>;
if (loading) return <div className="page-loading">{t("common.loading")}</div>;
if (!order) return <div className="alert alert-error">{t("admin.orderDetail.notFound")}</div>;
return (
<div>
<p>
<Link to="/admin/orders">&larr; Back to orders</Link>
<Link to="/admin/orders">&larr; {t("admin.orderDetail.backLink")}</Link>
</p>
<h1>Order {order.id.slice(0, 8)}</h1>
{error && <div className="alert alert-error">{error}</div>}
<h1>{t("admin.orderDetail.title", { id: order.id.slice(0, 8) })}</h1>
{loadError && <div className="alert alert-error">{loadError}</div>}
<div className="panel">
<h2>Customer</h2>
<h2>{t("admin.orderDetail.customerTitle")}</h2>
<p>
{order.customer_name} &mdash; {order.customer_email}
{order.customer_phone && `${order.customer_phone}`}
</p>
{order.notes && <p style={{ color: "var(--color-text-muted)" }}>Notes: {order.notes}</p>}
<div className="field" style={{ maxWidth: 240 }}>
<label htmlFor="status">Status</label>
<select id="status" value={order.status} onChange={(e) => handleStatusChange(e.target.value)} disabled={updating}>
{ORDER_STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{order.notes && (
<p style={{ color: "var(--color-text-muted)" }}>
{t("admin.orderDetail.notes")} {order.notes}
</p>
)}
</div>
<div className="panel">
<h2>Items</h2>
<h2>{t("admin.orderDetail.itemsTitle")}</h2>
<table>
<thead>
<tr>
<th>Product</th>
<th>Quantity</th>
<th>Multiplier</th>
<th>Unit price</th>
<th>Total</th>
<th>{t("admin.orderDetail.colProduct")}</th>
<th>{t("admin.orderDetail.colQuantity")}</th>
<th>{t("admin.orderDetail.colMultiplier")}</th>
<th>{t("admin.orderDetail.colUnitPrice")}</th>
<th>{t("admin.orderDetail.colTotal")}</th>
</tr>
</thead>
<tbody>
@@ -94,7 +72,7 @@ export default function OrderDetailPage() {
</tbody>
</table>
<p style={{ textAlign: "right", fontWeight: 600, marginTop: "0.75rem" }}>
Total: {formatCents(order.total_cents)}
{t("admin.orderDetail.total", { amount: formatCents(order.total_cents) })}
</p>
</div>
</div>
+12 -27
View File
@@ -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<Order[]>([]);
const [status, setStatus] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div>
<h1>Orders</h1>
<h1>{t("admin.orders.title")}</h1>
{error && <div className="alert alert-error">{error}</div>}
<div className="toolbar">
<select value={status} onChange={(e) => setStatus(e.target.value)} style={{ maxWidth: 200 }}>
<option value="">All statuses</option>
{ORDER_STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{loading ? (
<div className="page-loading">Loading</div>
<div className="page-loading">{t("common.loading")}</div>
) : (
<table>
<thead>
<tr>
<th>Customer</th>
<th>Email</th>
<th>Status</th>
<th>Total</th>
<th>{t("admin.orders.colCustomer")}</th>
<th>{t("admin.orders.colEmail")}</th>
<th>{t("admin.orders.colTotal")}</th>
<th></th>
</tr>
</thead>
@@ -52,13 +40,10 @@ export default function OrdersPage() {
<tr key={o.id}>
<td>{o.customer_name}</td>
<td>{o.customer_email}</td>
<td>
<span className="badge">{o.status}</span>
</td>
<td>{formatCents(o.total_cents)}</td>
<td>
<Link className="btn" to={`/admin/orders/${o.id}`}>
View
{t("admin.orders.view")}
</Link>
</td>
</tr>
+180 -72
View File
@@ -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<Product | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
@@ -13,7 +19,9 @@ export default function ProductEditPage() {
const [gallery, setGallery] = useState<string[]>([]);
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<string | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<Product>(`/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<Media>("/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<Product>(`/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 <div className="page-loading">Loading</div>;
if (loading || !product) return <div className="page-loading">{t("common.loading")}</div>;
return (
<div>
<p>
<Link to="/admin/products">&larr; Back to products</Link>
<Link to="/admin/products">&larr; {t("admin.productEdit.backLink")}</Link>
</p>
<h1>{product.name}</h1>
{message && <div className={`alert alert-${message.type}`}>{message.text}</div>}
{loadError && <div className="alert alert-error">{loadError}</div>}
<div className="panel">
<h2>Details</h2>
<h2>{t("admin.productEdit.detailsTitle")}</h2>
<form onSubmit={handleSave}>
<div className="form-row">
<div className="field">
<label htmlFor="name">Name</label>
<label htmlFor="name">{t("admin.productEdit.name")}</label>
<input id="name" value={product.name} onChange={(e) => setProduct({ ...product, name: e.target.value })} required />
</div>
<div className="field">
<label htmlFor="slug">Slug</label>
<input id="slug" value={product.slug} onChange={(e) => setProduct({ ...product, slug: e.target.value })} required />
</div>
<div className="field">
<label htmlFor="category">Category</label>
<label htmlFor="category">{t("admin.productEdit.category")}</label>
<select
id="category"
value={product.category_id ?? ""}
onChange={(e) => setProduct({ ...product, category_id: e.target.value || null })}
value={product.category_id}
onChange={(e) => setProduct({ ...product, category_id: e.target.value })}
required
>
<option value="">None</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
@@ -148,15 +194,7 @@ export default function ProductEditPage() {
</div>
</div>
<div className="field">
<label htmlFor="short">Short description</label>
<input
id="short"
value={product.short_description}
onChange={(e) => setProduct({ ...product, short_description: e.target.value })}
/>
</div>
<div className="field">
<label htmlFor="description">Description</label>
<label htmlFor="description">{t("admin.productEdit.description")}</label>
<textarea
id="description"
rows={4}
@@ -171,7 +209,7 @@ export default function ProductEditPage() {
checked={product.is_active}
onChange={(e) => setProduct({ ...product, is_active: e.target.checked })}
/>
<label htmlFor="active">Active</label>
<label htmlFor="active">{t("admin.productEdit.active")}</label>
</div>
<div className="field field-inline">
<input
@@ -180,36 +218,36 @@ export default function ProductEditPage() {
checked={product.is_featured}
onChange={(e) => setProduct({ ...product, is_featured: e.target.checked })}
/>
<label htmlFor="featured">Featured</label>
<label htmlFor="featured">{t("admin.productEdit.featured")}</label>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? "Saving" : "Save"}
{saving ? t("common.saving") : t("common.save")}
</button>
</div>
</form>
</div>
<div className="panel">
<h2>Quantity-based pricing</h2>
<h2>{t("admin.productEdit.pricingTitle")}</h2>
<table>
<thead>
<tr>
<th>Quantity</th>
<th>Unit</th>
<th>Price</th>
<th>{t("admin.productEdit.quantity")}</th>
<th>{t("admin.productEdit.unit")}</th>
<th>{t("admin.productEdit.price")}</th>
<th></th>
</tr>
</thead>
<tbody>
{tiers.map((t) => (
<tr key={t.id}>
<td>{t.quantity}</td>
<td>{unitSymbol(t.unit_id)}</td>
<td>{formatCents(t.price_cents)}</td>
{tiers.map((tier) => (
<tr key={tier.id}>
<td>{tier.quantity}</td>
<td>{unitSymbol(tier.unit_id)}</td>
<td>{formatCents(tier.price_cents)}</td>
<td>
<button className="btn btn-danger" onClick={() => handleDeleteTier(t.id)}>
Delete
<button className="btn btn-danger" onClick={() => handleDeleteTier(tier.id)}>
{t("common.delete")}
</button>
</td>
</tr>
@@ -220,11 +258,11 @@ export default function ProductEditPage() {
<form onSubmit={handleAddTier} style={{ marginTop: "1rem" }}>
<div className="form-row">
<div className="field">
<label htmlFor="quantity">Quantity</label>
<label htmlFor="quantity">{t("admin.productEdit.quantity")}</label>
<input id="quantity" name="quantity" type="number" step="any" min="0.001" required />
</div>
<div className="field">
<label htmlFor="unit_id">Unit</label>
<label htmlFor="unit_id">{t("admin.productEdit.unit")}</label>
<select id="unit_id" name="unit_id" required>
{units.map((u) => (
<option key={u.id} value={u.id}>
@@ -234,42 +272,112 @@ export default function ProductEditPage() {
</select>
</div>
<div className="field">
<label htmlFor="price">Price</label>
<label htmlFor="price">{t("admin.productEdit.price")}</label>
<input id="price" name="price" type="number" step="0.01" min="0" required />
</div>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary">
Add price tier
{t("admin.productEdit.addTier")}
</button>
</div>
</form>
</div>
<div className="panel">
<h2>Gallery</h2>
<p style={{ color: "var(--color-text-muted)" }}>Click a media item to attach/detach it from this product's gallery.</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))", gap: "0.75rem" }}>
<h2>{t("admin.productEdit.galleryTitle")}</h2>
<p style={{ color: "var(--color-text-muted)" }}>{t("admin.productEdit.galleryHint")}</p>
<form onSubmit={handleUploadAndAttach} style={{ marginBottom: "1.25rem" }}>
<div className="field">
<label htmlFor="gallery-file">{t("admin.productEdit.uploadLabel")}</label>
<input id="gallery-file" type="file" ref={fileInputRef} accept="image/*,video/mp4,video/webm" required />
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={uploading}>
{uploading ? t("common.uploading") : t("admin.productEdit.uploadButton")}
</button>
</div>
</form>
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fill, minmax(120px, 1fr))",
gap: "0.75rem",
}}
>
{mediaLibrary.map((m) => {
const attached = gallery.includes(m.id);
const isPrimary =
product.primary_media_id === m.id;
return (
<div
key={m.id}
onClick={() => 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/") ? (
<img src={m.url} alt={m.alt_text} style={{ width: "100%", height: 80, objectFit: "cover", borderRadius: 4 }} />
<img
src={m.url}
alt={m.alt_text}
style={{
width: "100%",
height: 80,
objectFit: "cover",
borderRadius: 4,
}}
/>
) : (
<div style={{ height: 80, display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem" }}>
<div
style={{
height: 80,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.7rem",
}}
>
{m.mime_type}
</div>
)}
<button
type="button"
className="btn"
style={{
width: "100%",
marginTop: 4,
fontSize: "0.7rem",
padding: "0.2rem",
}}
onClick={() => setPrimaryImage(m.id)}
>
{isPrimary
? t("admin.productEdit.isPrimary")
: t("admin.productEdit.setPrimary")}
</button>
<button
type="button"
className="btn btn-danger"
style={{
width: "100%",
marginTop: 4,
fontSize: "0.7rem",
padding: "0.2rem",
}}
onClick={() => handleDeleteMedia(m.id)}
>
{t("common.delete")}
</button>
</div>
);
})}
+701 -67
View File
@@ -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<Product[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [units, setUnits] = useState<Unit[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<File | null>(null);
const mediaInputRef = useRef<HTMLInputElement>(null);
// Price tiers
const [priceTiers, setPriceTiers] = useState<NewPriceTier[]>([
{
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<Product>("/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<PriceTier>(
`/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<Media>(
"/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<Product>(
`/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 (
<div>
<h1>Products</h1>
{error && <div className="alert alert-error">{error}</div>}
<h1>{t("admin.products.title")}</h1>
<div className="panel">
<h2>New product</h2>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
{/* Header / bouton de création */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
marginBottom: "1rem",
}}
>
<button
type="button"
className="btn btn-primary"
onClick={openCreateModal}
disabled={categories.length === 0}
>
{t("admin.products.createButton")}
</button>
</div>
{/* Aucune catégorie */}
{categories.length === 0 && (
<div className="alert alert-error">
{t("admin.products.noCategoriesPrefix")}{" "}
<Link to="/admin/categories">
{t("admin.products.noCategoriesLink")}
</Link>{" "}
{t("admin.products.noCategoriesSuffix")}
</div>
)}
{/* =========================================================
MODAL CRÉATION PRODUIT
========================================================= */}
<Modal
open={createModalOpen}
onClose={closeCreateModal}
labelledBy={t("admin.products.newTitle")}
>
<form onSubmit={handleCreate}>
<div className="form-row">
<div className="field">
<label htmlFor="p-name">Name</label>
<input id="p-name" value={name} onChange={(e) => setName(e.target.value)} required />
{/* Informations générales */}
<div className="panel">
<h3>{t("admin.productEdit.detailsTitle")}</h3>
<div className="form-row">
<div className="field">
<label htmlFor="p-name">
{t("admin.products.name")}
</label>
<input
id="p-name"
value={name}
onChange={(e) =>
setName(e.target.value)
}
required
autoFocus
/>
</div>
<div className="field">
<label htmlFor="p-category">
{t("admin.products.category")}
</label>
<select
id="p-category"
value={categoryId}
onChange={(e) =>
setCategoryId(e.target.value)
}
required
>
{categories.map((c) => (
<option
key={c.id}
value={c.id}
>
{c.name}
</option>
))}
</select>
</div>
</div>
<div className="field">
<label htmlFor="p-slug">Slug</label>
<input id="p-slug" value={slug} onChange={(e) => setSlug(e.target.value)} required />
<label htmlFor="p-description">
{t("admin.products.description")}
</label>
<textarea
id="p-description"
rows={4}
value={description}
onChange={(e) =>
setDescription(e.target.value)
}
/>
</div>
<div className="field">
<label htmlFor="p-category">Category</label>
<select id="p-category" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
<option value="">None</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<div
style={{
display: "flex",
gap: "1.5rem",
flexWrap: "wrap",
marginTop: "1rem",
}}
>
<div className="field field-inline">
<input
id="p-active"
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
/>
<label htmlFor="p-active">
{t("admin.productEdit.active")}
</label>
</div>
<div className="field field-inline">
<input
id="p-featured"
type="checkbox"
checked={isFeatured}
onChange={(e) => setIsFeatured(e.target.checked)}
/>
<label htmlFor="p-featured">
{t("admin.productEdit.featured")}
</label>
</div>
</div>
</div>
{/* =====================================================
PRIX PAR QUANTITÉ
===================================================== */}
<div
className="panel"
style={{ marginTop: "1rem" }}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "1rem",
}}
>
<h3 style={{ margin: 0 }}>
{t(
"admin.productEdit.pricingTitle",
)}
</h3>
<button
type="button"
className="btn"
onClick={addPriceTier}
disabled={creating || units.length === 0}
>
+ {t("admin.productEdit.addTier")}
</button>
</div>
{priceTiers.length === 0 ? (
<p
style={{
color: "var(--color-text-muted)",
marginTop: "1rem",
}}
>
{t("admin.productEdit.addTier")}
</p>
) : (
<div style={{ marginTop: "1rem" }}>
{priceTiers.map((tier, index) => (
<div
key={index}
style={{
display: "grid",
gridTemplateColumns:
"1fr 1.5fr 1fr auto",
gap: "0.75rem",
alignItems: "end",
marginBottom: "0.75rem",
}}
>
<div className="field">
<label
htmlFor={`tier-quantity-${index}`}
>
{t(
"admin.productEdit.quantity",
)}
</label>
<input
id={`tier-quantity-${index}`}
type="number"
step="any"
min="0.001"
value={tier.quantity}
onChange={(e) =>
updatePriceTier(
index,
"quantity",
e.target.value,
)
}
required
/>
</div>
<div className="field">
<label
htmlFor={`tier-unit-${index}`}
>
{t(
"admin.productEdit.unit",
)}
</label>
<select
id={`tier-unit-${index}`}
value={tier.unit_id}
onChange={(e) =>
updatePriceTier(
index,
"unit_id",
e.target.value,
)
}
required
>
<option value="">
</option>
{units.map((unit) => (
<option
key={unit.id}
value={unit.id}
>
{unit.name} ({unit.symbol})
</option>
))}
</select>
</div>
<div className="field">
<label
htmlFor={`tier-price-${index}`}
>
{t(
"admin.productEdit.price",
)}
</label>
<input
id={`tier-price-${index}`}
type="number"
step="0.01"
min="0"
value={tier.price}
onChange={(e) =>
updatePriceTier(
index,
"price",
e.target.value,
)
}
required
/>
</div>
<button
type="button"
className="btn btn-danger"
onClick={() =>
removePriceTier(index)
}
disabled={
creating ||
priceTiers.length === 1
}
title={t("common.delete")}
>
×
</button>
</div>
))}
</div>
)}
</div>
{/* =====================================================
MÉDIA
===================================================== */}
<div
className="panel"
style={{ marginTop: "1rem" }}
>
<h3>
{t(
"admin.productEdit.galleryTitle",
)}
</h3>
<p
style={{
color: "var(--color-text-muted)",
}}
>
{t(
"admin.productEdit.galleryHint",
)}
</p>
<div className="field">
<label htmlFor="p-media">
{t(
"admin.productEdit.uploadLabel",
)}
</label>
<input
id="p-media"
ref={mediaInputRef}
type="file"
accept="image/*,video/mp4,video/webm"
onChange={(e) =>
setMediaFile(
e.target.files?.[0] ?? null,
)
}
/>
</div>
{mediaFile && (
<div
style={{
marginTop: "0.5rem",
fontSize: "0.875rem",
color:
"var(--color-text-muted)",
}}
>
{mediaFile.name}
</div>
)}
</div>
{/* Actions */}
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={creating}>
{creating ? "Creating…" : "Create product"}
<button
type="button"
className="btn"
onClick={closeCreateModal}
disabled={creating}
>
{t("common.cancel")}
</button>
<button
type="submit"
className="btn btn-primary"
disabled={creating}
>
{creating
? t("common.creating")
: t(
"admin.products.createButton",
)}
</button>
</div>
</form>
</div>
</Modal>
{/* =========================================================
TABLEAU PRODUITS
========================================================= */}
{loading ? (
<div className="page-loading">Loading</div>
<div className="page-loading">
{t("common.loading")}
</div>
) : (
<table>
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Order</th>
<th>Status</th>
<th>Featured</th>
<th>
{t("admin.products.colName")}
</th>
<th>
{t(
"admin.products.colCategory",
)}
</th>
<th>
{t("admin.products.colOrder")}
</th>
<th>
{t(
"admin.products.colStatus",
)}
</th>
<th>
{t(
"admin.products.colFeatured",
)}
</th>
<th></th>
</tr>
</thead>
<tbody>
{products.map((p, index) => (
<tr key={p.id}>
<td>{p.name}</td>
<td>{categoryName(p.category_id)}</td>
<td>
<div style={{ display: "flex", alignItems: "center", gap: "0.4rem" }}>
{categoryName(p.category_id)}
</td>
<td>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.4rem",
}}
>
<button
type="button"
className="btn"
title="Move up"
title={t("common.moveUp")}
disabled={index === 0}
onClick={() => handleMove(index, "up")}
onClick={() =>
handleMove(index, "up")
}
>
</button>
<button
type="button"
className="btn"
title="Move down"
disabled={index === products.length - 1}
onClick={() => handleMove(index, "down")}
title={t("common.moveDown")}
disabled={
index ===
products.length - 1
}
onClick={() =>
handleMove(index, "down")
}
>
</button>
<span>{p.position}</span>
</div>
</td>
<td>
<span className={`badge ${p.is_active ? "badge-active" : "badge-inactive"}`}>
{p.is_active ? "active" : "inactive"}
<span
className={`badge ${
p.is_active
? "badge-active"
: "badge-inactive"
}`}
>
{p.is_active
? t("common.active")
: t("common.inactive")}
</span>
</td>
<td>{p.is_featured ? "★" : ""}</td>
<td>
<Link className="btn" to={`/admin/products/${p.id}`}>
Edit
{p.is_featured ? "★" : ""}
</td>
<td>
<Link
className="btn"
to={`/admin/products/${p.id}`}
>
{t("common.edit")}
</Link>{" "}
<button className="btn btn-danger" onClick={() => handleDelete(p.id)}>
Delete
<button
className="btn btn-danger"
onClick={() =>
handleDelete(p.id)
}
>
{t("common.delete")}
</button>
</td>
</tr>
@@ -1,48 +1,82 @@
import { useEffect, useState, type FormEvent } from "react";
import { apiFetch } from "../../api/client";
import type { SiteSettings } from "../../api/types";
import { useI18n } from "../../i18n/LanguageContext";
import { useToast } from "../../ui/ToastContext";
import { useAdminSiteSettings } from "./AdminSiteSettingsContext";
export default function SiteSettingsPage() {
const [settings, setSettings] = useState<SiteSettings>({ name: "", description: "", slug: "" });
const { t } = useI18n();
const toast = useToast();
const { refresh: refreshNavSettings } = useAdminSiteSettings();
const [settings, setSettings] = useState<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,
});
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<string | null>(null);
useEffect(() => {
apiFetch<SiteSettings>("/api/admin/site-settings")
.then(setSettings)
.catch(() => setMessage({ type: "error", text: "Failed to load site settings." }))
.catch(() => setLoadError(t("admin.siteSettings.loadError")))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setSaving(true);
setMessage(null);
try {
const updated = await apiFetch<SiteSettings>("/api/admin/site-settings", {
method: "PUT",
body: JSON.stringify(settings),
body: JSON.stringify({
name: settings.name,
description: settings.description,
orders_enabled: settings.orders_enabled,
}),
});
setSettings(updated);
setMessage({ type: "success", text: "Site settings saved." });
refreshNavSettings();
toast.success(t("admin.siteSettings.saved"));
} catch {
setMessage({ type: "error", text: "Failed to save site settings." });
toast.error(t("admin.siteSettings.saveError"));
} finally {
setSaving(false);
}
}
if (loading) return <div className="page-loading">Loading</div>;
if (loading) return <div className="page-loading">{t("common.loading")}</div>;
return (
<div>
<h1>General</h1>
<h1>{t("admin.siteSettings.title")}</h1>
<div className="panel">
{message && <div className={`alert alert-${message.type}`}>{message.text}</div>}
{loadError && <div className="alert alert-error">{loadError}</div>}
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="name">Site name</label>
<label htmlFor="name">{t("admin.siteSettings.siteName")}</label>
<input
id="name"
value={settings.name}
@@ -51,18 +85,7 @@ export default function SiteSettingsPage() {
/>
</div>
<div className="field">
<label htmlFor="slug">Slug</label>
<input
id="slug"
value={settings.slug}
onChange={(e) => setSettings({ ...settings, slug: e.target.value })}
pattern="^[a-z0-9]+(-[a-z0-9]+)*$"
title="lowercase letters, digits and hyphens only"
required
/>
</div>
<div className="field">
<label htmlFor="description">Description</label>
<label htmlFor="description">{t("admin.siteSettings.description")}</label>
<textarea
id="description"
rows={3}
@@ -70,9 +93,21 @@ export default function SiteSettingsPage() {
onChange={(e) => setSettings({ ...settings, description: e.target.value })}
/>
</div>
<div className="field field-inline">
<input
id="orders-enabled"
type="checkbox"
checked={settings.orders_enabled}
onChange={(e) => setSettings({ ...settings, orders_enabled: e.target.checked })}
/>
<label htmlFor="orders-enabled">{t("admin.siteSettings.ordersEnabled")}</label>
</div>
<p style={{ color: "var(--color-text-muted)", fontSize: "0.85rem", marginTop: "-0.5rem" }}>
{t("admin.siteSettings.ordersEnabledHint")}
</p>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? "Saving" : "Save"}
{saving ? t("common.saving") : t("common.save")}
</button>
</div>
</form>
@@ -1,20 +1,24 @@
import { useEffect, useState, type FormEvent } from "react";
import { apiFetch } from "../../api/client";
import type { TelegramSettings } from "../../api/types";
import { useI18n } from "../../i18n/LanguageContext";
import { useToast } from "../../ui/ToastContext";
export default function TelegramSettingsPage() {
const { t } = useI18n();
const toast = useToast();
const [settings, setSettings] = useState<TelegramSettings | null>(null);
const [botToken, setBotToken] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [message, setMessage] = useState<{ type: "error" | "success"; text: string } | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
function load() {
setLoading(true);
apiFetch<TelegramSettings>("/api/admin/notifications/telegram")
.then(setSettings)
.catch(() => setMessage({ type: "error", text: "Failed to load Telegram settings." }))
.catch(() => setLoadError(t("admin.telegram.loadError")))
.finally(() => setLoading(false));
}
@@ -24,7 +28,6 @@ export default function TelegramSettingsPage() {
e.preventDefault();
if (!settings) return;
setSaving(true);
setMessage(null);
try {
const updated = await apiFetch<TelegramSettings>("/api/admin/notifications/telegram", {
method: "PUT",
@@ -38,9 +41,9 @@ export default function TelegramSettingsPage() {
});
setSettings(updated);
setBotToken("");
setMessage({ type: "success", text: "Telegram settings saved." });
toast.success(t("admin.telegram.saved"));
} catch {
setMessage({ type: "error", text: "Failed to save Telegram settings." });
toast.error(t("admin.telegram.saveError"));
} finally {
setSaving(false);
}
@@ -48,23 +51,22 @@ export default function TelegramSettingsPage() {
async function handleTest() {
setTesting(true);
setMessage(null);
try {
await apiFetch("/api/admin/notifications/telegram/test", { method: "POST" });
setMessage({ type: "success", text: "Test message sent." });
toast.success(t("admin.telegram.testSent"));
} catch {
setMessage({ type: "error", text: "Failed to send test message. Check bot token/chat id." });
toast.error(t("admin.telegram.testError"));
} finally {
setTesting(false);
}
}
if (loading || !settings) return <div className="page-loading">Loading</div>;
if (loading || !settings) return <div className="page-loading">{t("common.loading")}</div>;
return (
<div>
<h1>Telegram notifications</h1>
{message && <div className={`alert alert-${message.type}`}>{message.text}</div>}
<h1>{t("admin.telegram.title")}</h1>
{loadError && <div className="alert alert-error">{loadError}</div>}
<div className="panel">
<form onSubmit={handleSubmit}>
@@ -75,12 +77,15 @@ export default function TelegramSettingsPage() {
checked={settings.enabled}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
/>
<label htmlFor="tg-enabled">Enabled</label>
<label htmlFor="tg-enabled">{t("admin.telegram.enabled")}</label>
</div>
<div className="field">
<label htmlFor="tg-token">
Bot token {settings.bot_token_configured && <span style={{ color: "var(--color-text-muted)" }}>(configured leave blank to keep it)</span>}
{t("admin.telegram.botToken")}{" "}
{settings.bot_token_configured && (
<span style={{ color: "var(--color-text-muted)" }}>{t("admin.telegram.botTokenConfigured")}</span>
)}
</label>
<input
id="tg-token"
@@ -92,7 +97,7 @@ export default function TelegramSettingsPage() {
</div>
<div className="field">
<label htmlFor="tg-chat">Chat ID</label>
<label htmlFor="tg-chat">{t("admin.telegram.chatId")}</label>
<input
id="tg-chat"
value={settings.chat_id}
@@ -107,24 +112,16 @@ export default function TelegramSettingsPage() {
checked={settings.notify_new_order}
onChange={(e) => setSettings({ ...settings, notify_new_order: e.target.checked })}
/>
<label htmlFor="tg-new-order">Notify on new order</label>
</div>
<div className="field field-inline">
<input
id="tg-status-change"
type="checkbox"
checked={settings.notify_status_change}
onChange={(e) => setSettings({ ...settings, notify_status_change: e.target.checked })}
/>
<label htmlFor="tg-status-change">Notify on order status change</label>
<label htmlFor="tg-new-order">{t("admin.telegram.notifyNewOrder")}</label>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? "Saving" : "Save"}
{saving ? t("common.saving") : t("common.save")}
</button>
<button type="button" className="btn" onClick={handleTest} disabled={testing}>
{testing ? "Sending" : "Send test message"}
{testing ? t("admin.telegram.sending") : t("admin.telegram.sendTest")}
</button>
</div>
</form>
+96 -51
View File
@@ -1,109 +1,154 @@
import { useEffect, useState, type FormEvent } from "react";
import { apiFetch } from "../../api/client";
import type { Unit } 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: "", symbol: "" };
export default function UnitsPage() {
const { t } = useI18n();
const confirm = useConfirm();
const toast = useToast();
const [units, setUnits] = useState<Unit[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [name, setName] = useState("");
const [symbol, setSymbol] = useState("");
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
function load() {
setLoading(true);
apiFetch<{ units: Unit[] }>("/api/admin/units")
.then((data) => setUnits(data.units))
.catch(() => setError("Failed to load units."))
.catch(() => setError(t("admin.units.loadError")))
.finally(() => setLoading(false));
}
useEffect(load, []);
function startEdit(unit: Unit) {
setEditingId(unit.id);
setName(unit.name);
setSymbol(unit.symbol);
function openCreateModal() {
setError(null);
setEditingId(null);
setForm(emptyForm);
setModalOpen(true);
}
function resetForm() {
function openEditModal(unit: Unit) {
setError(null);
setEditingId(unit.id);
setForm({ name: unit.name, symbol: unit.symbol });
setModalOpen(true);
}
function closeModal() {
if (saving) return;
setModalOpen(false);
setEditingId(null);
setName("");
setSymbol("");
setForm(emptyForm);
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setSaving(true);
setError(null);
try {
if (editingId) {
await apiFetch(`/api/admin/units/${editingId}`, { method: "PUT", body: JSON.stringify({ name, symbol }) });
await apiFetch(`/api/admin/units/${editingId}`, { method: "PUT", body: JSON.stringify(form) });
} else {
await apiFetch("/api/admin/units", { method: "POST", body: JSON.stringify({ name, symbol }) });
await apiFetch("/api/admin/units", { method: "POST", body: JSON.stringify(form) });
}
resetForm();
setModalOpen(false);
setEditingId(null);
setForm(emptyForm);
load();
toast.success(t("common.savedToast"));
} catch {
setError("Failed to save unit (symbol may already be in use).");
toast.error(t("admin.units.saveError"));
} finally {
setSaving(false);
}
}
async function handleDelete(id: string) {
if (!confirm("Delete this unit?")) return;
if (!(await confirm({ message: t("admin.units.confirmDelete"), danger: true }))) return;
try {
await apiFetch(`/api/admin/units/${id}`, { method: "DELETE" });
load();
toast.success(t("common.deletedToast"));
} catch {
setError("Failed to delete unit (it may still be used by price tiers).");
toast.error(t("admin.units.deleteError"));
}
}
return (
<div>
<h1>Units</h1>
<p style={{ color: "var(--color-text-muted)", marginTop: "-0.5rem" }}>
Define your own measurement units (kg, piece, box, ...) &mdash; not limited to a fixed list.
</p>
<h1>{t("admin.units.title")}</h1>
{error && <div className="alert alert-error">{error}</div>}
<div className="panel">
<h2>{editingId ? "Edit unit" : "New unit"}</h2>
<form onSubmit={handleSubmit}>
<div className="form-row">
<div className="field">
<label htmlFor="unit-name">Name</label>
<input id="unit-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Kilogram" required />
</div>
<div className="field">
<label htmlFor="unit-symbol">Symbol</label>
<input id="unit-symbol" value={symbol} onChange={(e) => setSymbol(e.target.value)} placeholder="kg" required />
</div>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? "Saving…" : editingId ? "Save changes" : "Create unit"}
</button>
{editingId && (
<button type="button" className="btn" onClick={resetForm}>
Cancel
</button>
)}
</div>
</form>
<div
style={{
display: "flex",
justifyContent: "flex-end",
alignItems: "center",
marginBottom: "1rem",
}}
>
<button type="button" className="btn btn-primary" onClick={openCreateModal}>
{t("admin.units.createButton")}
</button>
</div>
<Modal open={modalOpen} onClose={closeModal} labelledBy={editingId ? t("admin.units.editTitle") : t("admin.units.newTitle")}>
<div className="panel">
<h3>{editingId ? t("admin.units.editTitle") : t("admin.units.newTitle")}</h3>
<form onSubmit={handleSubmit}>
<div className="form-row">
<div className="field">
<label htmlFor="unit-name">{t("admin.units.name")}</label>
<input
id="unit-name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="Kilogram"
required
autoFocus
/>
</div>
<div className="field">
<label htmlFor="unit-symbol">{t("admin.units.symbol")}</label>
<input
id="unit-symbol"
value={form.symbol}
onChange={(e) => setForm({ ...form, symbol: e.target.value })}
placeholder="kg"
required
/>
</div>
</div>
<div className="form-actions">
<button type="button" className="btn" onClick={closeModal} disabled={saving}>
{t("common.cancel")}
</button>
<button type="submit" className="btn btn-primary" disabled={saving}>
{saving ? t("common.saving") : editingId ? t("admin.units.saveChanges") : t("admin.units.createButton")}
</button>
</div>
</form>
</div>
</Modal>
{loading ? (
<div className="page-loading">Loading</div>
<div className="page-loading">{t("common.loading")}</div>
) : (
<table>
<thead>
<tr>
<th>Name</th>
<th>Symbol</th>
<th>{t("admin.units.name")}</th>
<th>{t("admin.units.symbol")}</th>
<th></th>
</tr>
</thead>
@@ -113,11 +158,11 @@ export default function UnitsPage() {
<td>{u.name}</td>
<td>{u.symbol}</td>
<td>
<button className="btn" onClick={() => startEdit(u)}>
Edit
<button className="btn" onClick={() => openEditModal(u)}>
{t("common.edit")}
</button>{" "}
<button className="btn btn-danger" onClick={() => handleDelete(u.id)}>
Delete
{t("common.delete")}
</button>
</td>
</tr>
+46 -24
View File
@@ -1,13 +1,26 @@
import { useEffect, useState, type FormEvent } from "react";
import { apiFetch } from "../../api/client";
import type { User } from "../../api/types";
import { useI18n } from "../../i18n/LanguageContext";
import { useConfirm } from "../../ui/ConfirmContext";
import { useToast } from "../../ui/ToastContext";
import { useAdminSiteSettings } from "./AdminSiteSettingsContext";
export default function UsersPage() {
const { t } = useI18n();
const confirm = useConfirm();
const toast = useToast();
const { settings } = useAdminSiteSettings();
// Creating a "customer" user from the admin panel is only meaningful if
// customer accounts are usable in some form (able to log in and/or
// self-register); otherwise that account could never sign in anywhere.
const customerRoleAvailable = settings.customer_login_enabled || settings.customer_registration_enabled;
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("admin");
const [creating, setCreating] = useState(false);
@@ -16,56 +29,65 @@ export default function UsersPage() {
setLoading(true);
apiFetch<{ users: User[] }>("/api/admin/users")
.then((data) => setUsers(data.users))
.catch(() => setError("Failed to load users."))
.catch(() => setError(t("admin.users.loadError")))
.finally(() => setLoading(false));
}
useEffect(load, []);
useEffect(() => {
if (!customerRoleAvailable && role === "customer") setRole("admin");
}, [customerRoleAvailable, role]);
async function handleCreate(e: FormEvent) {
e.preventDefault();
setCreating(true);
setError(null);
try {
await apiFetch("/api/admin/users", {
method: "POST",
body: JSON.stringify({ email, password, role }),
body: JSON.stringify({ username, password, role }),
});
setEmail("");
setUsername("");
setPassword("");
load();
toast.success(t("common.createdToast"));
} catch {
setError("Failed to create user. Password must be at least 12 characters.");
toast.error(t("admin.users.createError"));
} finally {
setCreating(false);
}
}
async function handleDelete(id: string) {
if (!confirm("Delete this user?")) return;
if (!(await confirm({ message: t("admin.users.confirmDelete"), danger: true }))) return;
try {
await apiFetch(`/api/admin/users/${id}`, { method: "DELETE" });
load();
toast.success(t("common.deletedToast"));
} catch {
setError("Failed to delete user.");
toast.error(t("admin.users.deleteError"));
}
}
return (
<div>
<h1>Users</h1>
<h1>{t("admin.users.title")}</h1>
{error && <div className="alert alert-error">{error}</div>}
<div className="panel">
<h2>Create user</h2>
<h2>{t("admin.users.createTitle")}</h2>
<form onSubmit={handleCreate}>
<div className="form-row">
<div className="field">
<label htmlFor="new-email">Email</label>
<input id="new-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
<label htmlFor="new-username">{t("admin.users.username")}</label>
<input id="new-username" type="text" value={username} onChange={(e) => setUsername(e.target.value)} required />
</div>
<div className="field">
<label htmlFor="new-password">Password</label>
<label htmlFor="new-password">{t("admin.users.password")}</label>
<input
id="new-password"
type="password"
@@ -76,46 +98,46 @@ export default function UsersPage() {
/>
</div>
<div className="field">
<label htmlFor="new-role">Role</label>
<label htmlFor="new-role">{t("admin.users.role")}</label>
<select id="new-role" value={role} onChange={(e) => setRole(e.target.value)}>
<option value="admin">Admin</option>
<option value="customer">Customer</option>
<option value="admin">{t("admin.users.roleAdmin")}</option>
{customerRoleAvailable && <option value="customer">{t("admin.users.roleCustomer")}</option>}
</select>
</div>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={creating}>
{creating ? "Creating" : "Create user"}
{creating ? t("common.creating") : t("admin.users.createButton")}
</button>
</div>
</form>
</div>
{loading ? (
<div className="page-loading">Loading</div>
<div className="page-loading">{t("common.loading")}</div>
) : (
<table>
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>{t("admin.users.colUsername")}</th>
<th>{t("admin.users.colRole")}</th>
<th>{t("admin.users.colStatus")}</th>
<th></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.email}</td>
<td>{u.username}</td>
<td>{u.role}</td>
<td>
<span className={`badge ${u.is_active ? "badge-active" : "badge-inactive"}`}>
{u.is_active ? "active" : "inactive"}
{u.is_active ? t("common.active") : t("common.inactive")}
</span>
</td>
<td>
<button className="btn btn-danger" onClick={() => handleDelete(u.id)}>
Delete
{t("common.delete")}
</button>
</td>
</tr>
+3 -3
View File
@@ -10,7 +10,7 @@ interface LoginResponse {
interface AuthContextValue {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
@@ -31,10 +31,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
.finally(() => setLoading(false));
}, []);
async function login(email: string, password: string) {
async function login(username: string, password: string) {
const data = await apiFetch<LoginResponse>("/api/auth/admin/login", {
method: "POST",
body: JSON.stringify({ email, password }),
body: JSON.stringify({ username, password }),
});
setAccessToken(data.access_token);
setUser(data.user);
+15 -13
View File
@@ -2,11 +2,13 @@ import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "./AuthContext";
import { ApiError } from "../../api/client";
import { useI18n } from "../../i18n/LanguageContext";
export default function LoginPage() {
const { login } = useAuth();
const { t } = useI18n();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
@@ -16,15 +18,15 @@ export default function LoginPage() {
setError(null);
setSubmitting(true);
try {
await login(email, password);
await login(username, password);
navigate("/admin", { replace: true });
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
setError("Invalid email or password.");
setError(t("auth.login.errorInvalid"));
} else if (err instanceof ApiError && err.status === 429) {
setError("Too many attempts. Please wait a moment and try again.");
setError(t("auth.login.errorRateLimited"));
} else {
setError("Something went wrong. Please try again.");
setError(t("auth.login.errorGeneric"));
}
} finally {
setSubmitting(false);
@@ -34,22 +36,22 @@ export default function LoginPage() {
return (
<div className="auth-screen">
<div className="auth-card">
<h1>Admin sign in</h1>
<h1>{t("auth.login.title")}</h1>
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="email">Email</label>
<label htmlFor="username">{t("auth.login.username")}</label>
<input
id="email"
type="email"
id="username"
type="text"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="field">
<label htmlFor="password">Password</label>
<label htmlFor="password">{t("auth.login.password")}</label>
<input
id="password"
type="password"
@@ -61,7 +63,7 @@ export default function LoginPage() {
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? "Signing in" : "Sign in"}
{submitting ? t("auth.login.submitting") : t("auth.login.submit")}
</button>
</div>
</form>
@@ -0,0 +1,139 @@
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client";
import { useCustomerAuth } from "./CustomerAuthContext";
import { useSiteSettings } from "./SiteSettingsContext";
import { useI18n } from "../../i18n/LanguageContext";
import OrderHistoryPanel from "./OrderHistoryPanel";
export default function AccountPage() {
const { t } = useI18n();
const { customer, loading, login, register, logout } = useCustomerAuth();
const site = useSiteSettings();
const navigate = useNavigate();
// Registration is a sub-toggle of login (creating an account signs the
// customer in immediately, so it can't work with login off) -- whenever
// this form can render at all, login is enabled, so login is always the
// starting tab.
const [mode, setMode] = useState<"login" | "register">("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
if (loading) return <div className="page-loading">{t("common.loading")}</div>;
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
if (mode === "login") {
await login(username, password);
} else {
await register(username, password);
}
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 403) {
setError(mode === "login" ? t("storefront.account.errorLoginDisabled") : t("storefront.account.errorRegistrationDisabled"));
} else if (err.status === 409) {
setError(t("storefront.account.errorUsernameTaken"));
} else if (err.status === 401) {
setError(t("storefront.account.errorInvalidCredentials"));
} else {
setError(err.message);
}
} else {
setError(t("storefront.account.errorGeneric"));
}
} finally {
setSubmitting(false);
}
}
if (!customer) {
if (!site.customer_login_enabled) {
return (
<div>
<h1>{t("storefront.account.title")}</h1>
<p>{t("storefront.account.unavailable")}</p>
</div>
);
}
return (
<div className="auth-screen" style={{ minHeight: "auto", padding: "2rem 1rem" }}>
<div className="auth-card">
<h1>{mode === "login" ? t("storefront.account.signIn") : t("storefront.account.createAccount")}</h1>
{error && <div className="alert alert-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="username">{t("storefront.account.username")}</label>
<input id="username" value={username} onChange={(e) => setUsername(e.target.value)} required />
</div>
<div className="field">
<label htmlFor="password">{t("storefront.account.password")}</label>
<input
id="password"
type="password"
minLength={mode === "register" ? 8 : undefined}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting
? t("storefront.account.pleaseWait")
: mode === "login"
? t("storefront.account.submitSignIn")
: t("storefront.account.submitCreate")}
</button>
</div>
</form>
{site.customer_registration_enabled && (
<p style={{ marginTop: "1rem", fontSize: "0.9rem" }}>
{mode === "login" ? (
<>
{t("storefront.account.noAccountYet")}{" "}
<button type="button" className="btn" onClick={() => setMode("register")}>
{t("storefront.account.register")}
</button>
</>
) : (
<>
{t("storefront.account.alreadyHaveAccount")}{" "}
<button type="button" className="btn" onClick={() => setMode("login")}>
{t("storefront.account.signIn")}
</button>
</>
)}
</p>
)}
</div>
</div>
);
}
return (
<div>
<div className="toolbar">
<h1>{t("storefront.account.myAccountTitle")}</h1>
<button
className="btn"
onClick={async () => {
await logout();
navigate("/");
}}
>
{t("storefront.account.logout")}
</button>
</div>
<p>{t("storefront.account.signedInAs", { username: customer.username })}</p>
<OrderHistoryPanel />
</div>
);
}
@@ -0,0 +1,82 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
export interface CartItem {
productId: string;
productName: string;
priceTierId: string;
unitLabel: string;
unitPriceCents: number;
multiplier: number;
}
interface CartContextValue {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (priceTierId: string) => void;
setMultiplier: (priceTierId: string, multiplier: number) => void;
clear: () => void;
totalCents: number;
itemCount: number;
}
const STORAGE_KEY = "storefront_cart";
const CartContext = createContext<CartContextValue | undefined>(undefined);
function loadCart(): CartItem[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as CartItem[]) : [];
} catch {
return [];
}
}
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>(loadCart);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
}, [items]);
function addItem(item: CartItem) {
setItems((current) => {
const existing = current.find((i) => i.priceTierId === item.priceTierId);
if (existing) {
return current.map((i) =>
i.priceTierId === item.priceTierId ? { ...i, multiplier: i.multiplier + item.multiplier } : i,
);
}
return [...current, item];
});
}
function removeItem(priceTierId: string) {
setItems((current) => current.filter((i) => i.priceTierId !== priceTierId));
}
function setMultiplier(priceTierId: string, multiplier: number) {
setItems((current) =>
current.map((i) => (i.priceTierId === priceTierId ? { ...i, multiplier: Math.max(1, multiplier) } : i)),
);
}
function clear() {
setItems([]);
}
const totalCents = items.reduce((sum, i) => sum + i.unitPriceCents * i.multiplier, 0);
const itemCount = items.reduce((sum, i) => sum + i.multiplier, 0);
return (
<CartContext.Provider value={{ items, addItem, removeItem, setMultiplier, clear, totalCents, itemCount }}>
{children}
</CartContext.Provider>
);
}
export function useCart(): CartContextValue {
const ctx = useContext(CartContext);
if (!ctx) throw new Error("useCart must be used within a CartProvider");
return ctx;
}
@@ -0,0 +1,83 @@
import { Link, useNavigate } from "react-router-dom";
import { formatCents } from "../../api/types";
import { useCart } from "./CartContext";
import { useSiteSettings } from "./SiteSettingsContext";
import { useI18n } from "../../i18n/LanguageContext";
export default function CartPage() {
const { t } = useI18n();
const { items, removeItem, setMultiplier, totalCents } = useCart();
const site = useSiteSettings();
const navigate = useNavigate();
if (!site.orders_enabled || !site.customer_login_enabled) {
return (
<div>
<h1>{t("storefront.cart.title")}</h1>
<p>{t("storefront.ordersDisabled")}</p>
</div>
);
}
if (items.length === 0) {
return (
<div>
<h1>{t("storefront.cart.title")}</h1>
<p>
{t("storefront.cart.empty")} <Link to="/">{t("storefront.cart.browseCatalog")}</Link>.
</p>
</div>
);
}
return (
<div>
<h1>{t("storefront.cart.title")}</h1>
<table>
<thead>
<tr>
<th>{t("storefront.cart.colProduct")}</th>
<th>{t("storefront.cart.colOption")}</th>
<th>{t("storefront.cart.colQuantity")}</th>
<th>{t("storefront.cart.colSubtotal")}</th>
<th></th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.priceTierId}>
<td>{item.productName}</td>
<td>{item.unitLabel}</td>
<td>
<input
type="number"
min="1"
style={{ width: 70 }}
value={item.multiplier}
onChange={(e) => setMultiplier(item.priceTierId, Number(e.target.value))}
/>
</td>
<td>{formatCents(item.unitPriceCents * item.multiplier)}</td>
<td>
<button className="btn btn-danger" onClick={() => removeItem(item.priceTierId)}>
{t("storefront.cart.remove")}
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="panel" style={{ marginTop: "1rem" }}>
<p>
<strong>{t("storefront.cart.total", { amount: formatCents(totalCents) })}</strong>
</p>
<div className="form-actions">
<button className="btn btn-primary" onClick={() => navigate("/checkout")}>
{t("storefront.cart.checkout")}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,139 @@
import { useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { ApiError, customerApiFetch } from "../../api/client";
import type { Order } from "../../api/types";
import { useCart } from "./CartContext";
import { useCustomerAuth } from "./CustomerAuthContext";
import { useSiteSettings } from "./SiteSettingsContext";
import { useI18n } from "../../i18n/LanguageContext";
export default function CheckoutPage() {
const { t } = useI18n();
const { items, totalCents, clear } = useCart();
const { customer, loading: authLoading } = useCustomerAuth();
const site = useSiteSettings();
const navigate = useNavigate();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!site.orders_enabled || !site.customer_login_enabled) {
return (
<div>
<h1>{t("storefront.checkout.title")}</h1>
<p>{t("storefront.ordersDisabled")}</p>
</div>
);
}
if (items.length === 0) {
return (
<div>
<h1>{t("storefront.checkout.title")}</h1>
<p>
{t("storefront.checkout.emptyCart")} <Link to="/">{t("storefront.checkout.browseCatalog")}</Link>.
</p>
</div>
);
}
// Customer accounts are enabled (checked above), so a signed-in customer
// is required (see backend orders.RequireAccountsEnabled) -- send people
// there instead of letting them hit a confusing 401 from the API.
if (!authLoading && !customer) {
return (
<div>
<h1>{t("storefront.checkout.title")}</h1>
<div className="alert alert-error">
{t("storefront.checkout.loginRequiredPrefix")}{" "}
<Link to="/account">{t("storefront.checkout.loginRequiredLink")}</Link>
{t("storefront.checkout.loginRequiredSuffix")}
</div>
</div>
);
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
// customerApiFetch attaches the logged-in customer's bearer token
// (when there is one) so RequireCustomer/RequireApprovedVerification
// can authenticate the request; guests simply send no token.
const order = await customerApiFetch<Order>("/api/orders", {
method: "POST",
body: JSON.stringify({
customer_name: name,
customer_email: email,
customer_phone: phone,
notes,
items: items.map((item) => ({
product_id: item.productId,
price_tier_id: item.priceTierId,
multiplier: item.multiplier,
})),
}),
});
clear();
navigate("/order-confirmation", { state: { order } });
} catch (err) {
if (err instanceof ApiError && (err.status === 403 || err.status === 401)) {
// Surface the backend's own message: it explains *why* checkout is
// blocked (e.g. "ordering requires a customer account, which is
// currently disabled" or "identity verification must be approved
// before ordering") rather than a generic failure.
setError(err.message);
} else {
setError(t("storefront.checkout.genericError"));
}
} finally {
setSubmitting(false);
}
}
return (
<div>
<h1>{t("storefront.checkout.title")}</h1>
{error && <div className="alert alert-error">{error}</div>}
<div className="panel">
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="name">{t("storefront.checkout.fullName")}</label>
<input id="name" value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div className="field">
<label htmlFor="checkout-email">{t("storefront.checkout.email")}</label>
<input
id="checkout-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="field">
<label htmlFor="phone">{t("storefront.checkout.phone")}</label>
<input id="phone" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<div className="field">
<label htmlFor="notes">{t("storefront.checkout.notes")}</label>
<textarea id="notes" rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<p>
<strong>{t("storefront.checkout.total", { amount: (totalCents / 100).toFixed(2) })}</strong>
</p>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? t("storefront.checkout.submitting") : t("storefront.checkout.submit")}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import { apiFetch } from "../../api/client";
import type { ContactLink } from "../../api/types";
import { useMediaUrl } from "./useMediaUrl";
import { useSiteSettings } from "./SiteSettingsContext";
import { useI18n } from "../../i18n/LanguageContext";
import { getSocialIcon, SocialIconGlyph } from "../../socialIcons";
function ContactLinkCard({ link }: { link: ContactLink }) {
const iconUrl = useMediaUrl(link.icon_media_id);
const brandIcon = getSocialIcon(link.icon_key);
return (
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="panel contact-link-card"
style={{ display: "flex", alignItems: "center", gap: "0.9rem", textDecoration: "none", color: "var(--color-text)" }}
>
<span
style={{
width: 44,
height: 44,
borderRadius: "50%",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: link.color || brandIcon?.defaultColor || "var(--color-primary)",
color: "#fff",
overflow: "hidden",
}}
>
{brandIcon ? (
<SocialIconGlyph path={brandIcon.path} className="social-icon-sm" />
) : iconUrl ? (
<img src={iconUrl} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
) : (
link.label.charAt(0).toUpperCase()
)}
</span>
<span style={{ fontWeight: 600 }}>{link.label}</span>
</a>
);
}
export default function ContactPage() {
const { t } = useI18n();
const site = useSiteSettings();
const [links, setLinks] = useState<ContactLink[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
apiFetch<{ contact_links: ContactLink[] }>("/api/contact-links")
.then((data) => setLinks(data.contact_links))
.catch(() => setLinks([]))
.finally(() => setLoading(false));
}, []);
return (
<div>
<h1>{t("storefront.contact.title")}</h1>
{site.description && <p style={{ color: "var(--color-text-muted)" }}>{site.description}</p>}
{loading ? (
<div className="page-loading">{t("common.loading")}</div>
) : links.length === 0 ? (
<p>{t("storefront.contact.empty")}</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", maxWidth: 420 }}>
{links.map((link) => (
<ContactLinkCard key={link.id} link={link} />
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,105 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { customerApiFetch, setCustomerAccessToken } from "../../api/client";
import type { CustomerVerification, User } from "../../api/types";
interface AuthResponse {
access_token: string;
user: User;
}
interface CustomerAuthContextValue {
customer: User | null;
loading: boolean;
// null = no submission yet (or not applicable); a customer only ever has
// one verification record (front+back, re-submitted in place on
// rejection -- see backend customerverification.Repository.Upsert).
verification: CustomerVerification | null;
verificationLoading: boolean;
refreshVerification: () => Promise<void>;
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
const CustomerAuthContext = createContext<CustomerAuthContextValue | undefined>(undefined);
export function CustomerAuthProvider({ children }: { children: ReactNode }) {
const [customer, setCustomer] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [verification, setVerification] = useState<CustomerVerification | null>(null);
const [verificationLoading, setVerificationLoading] = useState(false);
async function refreshVerification() {
setVerificationLoading(true);
try {
const v = await customerApiFetch<CustomerVerification>("/api/auth/customer/verification");
setVerification(v);
} catch {
// A 404 (nothing submitted yet) is the expected case for a new
// account; any other failure also just falls back to "not verified".
setVerification(null);
} finally {
setVerificationLoading(false);
}
}
useEffect(() => {
// Same pattern as the admin AuthContext: this first call is expected to
// 401 on a fresh page load, which triggers a silent refresh-via-cookie
// attempt before giving up and treating the visitor as a guest.
customerApiFetch<User>("/api/auth/customer/me")
.then(async (u) => {
setCustomer(u);
await refreshVerification();
})
.catch(() => setCustomer(null))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function login(username: string, password: string) {
const data = await customerApiFetch<AuthResponse>("/api/auth/customer/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
setCustomerAccessToken(data.access_token);
setCustomer(data.user);
await refreshVerification();
}
async function register(username: string, password: string) {
const data = await customerApiFetch<AuthResponse>("/api/auth/customer/register", {
method: "POST",
body: JSON.stringify({ username, password }),
});
setCustomerAccessToken(data.access_token);
setCustomer(data.user);
// A brand new account never has a submission yet; skip the network
// round-trip and let the verification gate show the empty submit form.
setVerification(null);
}
async function logout() {
try {
await customerApiFetch("/api/auth/customer/logout", { method: "POST" });
} finally {
setCustomerAccessToken(null);
setCustomer(null);
setVerification(null);
}
}
return (
<CustomerAuthContext.Provider
value={{ customer, loading, verification, verificationLoading, refreshVerification, login, register, logout }}
>
{children}
</CustomerAuthContext.Provider>
);
}
export function useCustomerAuth(): CustomerAuthContextValue {
const ctx = useContext(CustomerAuthContext);
if (!ctx) throw new Error("useCustomerAuth must be used within a CustomerAuthProvider");
return ctx;
}
@@ -0,0 +1,7 @@
import { useMediaUrl } from "./useMediaUrl";
export default function GalleryThumb({ mediaId, alt }: { mediaId: string; alt: string }) {
const url = useMediaUrl(mediaId);
if (!url) return null;
return <img src={url} alt={alt} className="product-gallery-thumb" />;
}
@@ -0,0 +1,169 @@
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { useSearchParams } from "react-router-dom";
import { apiFetch } from "../../api/client";
import type { Category, Product, SiteSettings } from "../../api/types";
import ProductCard from "./ProductCard";
import { useSiteSettings } from "./SiteSettingsContext";
import { useI18n } from "../../i18n/LanguageContext";
import { useMediaUrl } from "./useMediaUrl";
import { ChevronLeftIcon, ChevronRightIcon } from "../../ui/icons";
function productCountLabel(t: ReturnType<typeof useI18n>["t"], count: number) {
return t(count === 1 ? "storefront.home.productCount" : "storefront.home.productCountPlural", { count });
}
function ProductList({
products,
layout,
columns,
scroll,
}: {
products: Product[];
layout: SiteSettings["product_layout"];
columns: number;
scroll: SiteSettings["product_scroll"];
}) {
const scrollRef = useRef<HTMLDivElement>(null);
function scrollByAmount(direction: 1 | -1) {
scrollRef.current?.scrollBy({ left: direction * 320, behavior: "smooth" });
}
if (scroll === "horizontal") {
return (
<div className="product-carousel-wrap">
<button type="button" className="product-carousel-arrow product-carousel-arrow-left" onClick={() => scrollByAmount(-1)} aria-label="Scroll left">
<ChevronLeftIcon />
</button>
<div className="product-carousel" ref={scrollRef}>
{products.map((p) => (
<ProductCard key={p.id} product={p} layout={layout} horizontal />
))}
</div>
<button type="button" className="product-carousel-arrow product-carousel-arrow-right" onClick={() => scrollByAmount(1)} aria-label="Scroll right">
<ChevronRightIcon />
</button>
</div>
);
}
return (
<div
className={layout === "list" ? "product-list" : "product-grid"}
style={layout !== "list" && columns > 0 ? ({ "--product-columns-template": `repeat(${columns}, 1fr)` } as CSSProperties) : undefined}
>
{products.map((p) => (
<ProductCard key={p.id} product={p} layout={layout} />
))}
</div>
);
}
function CategoryCard({
category,
representativeProduct,
productCount,
onSelect,
}: {
category: Category;
representativeProduct: Product | undefined;
productCount: number;
onSelect: (id: string) => void;
}) {
const { t } = useI18n();
const categoryImageUrl = useMediaUrl(category.media_id);
const productImageUrl = useMediaUrl(representativeProduct?.primary_media_id);
const imageUrl = categoryImageUrl ?? productImageUrl;
return (
<button type="button" className="category-card" onClick={() => onSelect(category.id)}>
{imageUrl && <img src={imageUrl} alt={category.name} />}
<div className="category-card-overlay">
<h3>{category.name}</h3>
<span>{productCountLabel(t, productCount)}</span>
</div>
</button>
);
}
export default function HomePage() {
const site = useSiteSettings();
const { t } = useI18n();
const [categories, setCategories] = useState<Category[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [searchParams, setSearchParams] = useSearchParams();
const categoryId = searchParams.get("category_id") ?? "";
useEffect(() => {
setLoading(true);
Promise.all([
apiFetch<{ categories: Category[] }>("/api/categories"),
apiFetch<{ products: Product[] }>("/api/products"),
])
.then(([c, p]) => {
setCategories(c.categories);
setProducts(p.products);
})
.catch(() => {
setCategories([]);
setProducts([]);
})
.finally(() => setLoading(false));
}, []);
function selectCategory(id: string) {
setSearchParams(id ? { category_id: id } : {});
}
if (loading) {
return <div className="page-loading">{t("common.loading")}</div>;
}
const selectedCategory = categories.find((c) => c.id === categoryId);
if (!selectedCategory) {
return (
<div>
{categories.length === 0 ? (
<p>{t("storefront.home.noCategories")}</p>
) : (
<div className="category-grid">
{categories.map((cat) => {
const categoryProducts = products.filter((p) => p.category_id === cat.id);
return (
<CategoryCard
key={cat.id}
category={cat}
representativeProduct={categoryProducts.find((p) => p.primary_media_id)}
productCount={categoryProducts.length}
onSelect={selectCategory}
/>
);
})}
</div>
)}
</div>
);
}
const filteredProducts = products.filter((p) => p.category_id === selectedCategory.id);
return (
<div>
<button type="button" className="storefront-back-link" onClick={() => selectCategory("")}>
{t("storefront.home.backToCategories")}
</button>
<div className="storefront-category-header">
<h2>{selectedCategory.name}</h2>
<span className="storefront-category-count">{productCountLabel(t, filteredProducts.length)}</span>
</div>
{filteredProducts.length === 0 ? (
<p>{t("storefront.home.noProducts")}</p>
) : (
<ProductList products={filteredProducts} layout={site.product_layout} columns={site.product_columns} scroll={site.product_scroll} />
)}
</div>
);
}
@@ -0,0 +1,52 @@
import { Link, useLocation, useNavigate } from "react-router-dom";
import { formatCents, type Order } from "../../api/types";
import { useI18n } from "../../i18n/LanguageContext";
export default function OrderConfirmationPage() {
const { t } = useI18n();
const location = useLocation();
const navigate = useNavigate();
const order = (location.state as { order?: Order } | null)?.order;
if (!order) {
navigate("/", { replace: true });
return null;
}
return (
<div>
<h1>{t("storefront.orderConfirmation.title")}</h1>
<div className="panel">
<p>{t("storefront.orderConfirmation.thanks", { name: order.customer_name })}</p>
<table>
<thead>
<tr>
<th>{t("storefront.orderConfirmation.colProduct")}</th>
<th>{t("storefront.orderConfirmation.colOption")}</th>
<th>{t("storefront.orderConfirmation.colQuantity")}</th>
<th>{t("storefront.orderConfirmation.colSubtotal")}</th>
</tr>
</thead>
<tbody>
{order.items?.map((item, i) => (
<tr key={i}>
<td>{item.product_name}</td>
<td>
{item.tier_quantity} {item.unit_symbol}
</td>
<td>{item.multiplier}</td>
<td>{formatCents(item.total_cents)}</td>
</tr>
))}
</tbody>
</table>
<p>
<strong>{t("storefront.orderConfirmation.total", { amount: formatCents(order.total_cents) })}</strong>
</p>
</div>
<Link to="/" className="btn btn-primary">
{t("storefront.orderConfirmation.backLink")}
</Link>
</div>
);
}
@@ -0,0 +1,45 @@
import { useEffect, useState } from "react";
import { customerApiFetch } from "../../api/client";
import { formatCents, type Order } from "../../api/types";
import { useI18n } from "../../i18n/LanguageContext";
export default function OrderHistoryPanel() {
const { t } = useI18n();
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
customerApiFetch<{ orders: Order[] }>("/api/customer/orders")
.then((data) => setOrders(data.orders))
.catch(() => setOrders([]))
.finally(() => setLoading(false));
}, []);
return (
<div className="panel">
<h2>{t("storefront.orderHistory.title")}</h2>
{loading ? (
<div className="page-loading">{t("common.loading")}</div>
) : orders.length === 0 ? (
<p>{t("storefront.orderHistory.empty")}</p>
) : (
<table>
<thead>
<tr>
<th>{t("storefront.orderHistory.colOrder")}</th>
<th>{t("storefront.orderHistory.colTotal")}</th>
</tr>
</thead>
<tbody>
{orders.map((o) => (
<tr key={o.id}>
<td>{o.id.slice(0, 8)}</td>
<td>{formatCents(o.total_cents)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
@@ -0,0 +1,78 @@
import { Link } from "react-router-dom";
import { formatCents, type Product, type SiteSettings } from "../../api/types";
import { useMediaUrl } from "./useMediaUrl";
import { usePriceTiers } from "./usePriceTiers";
import { useUnits } from "./useUnits";
import { ImageIcon } from "../../ui/icons";
const LAYOUT_CLASSES: Record<SiteSettings["product_layout"], string> = {
grid: "product-card",
"grid-overlay": "product-card product-card-overlay",
"grid-minimal": "product-card product-card-minimal",
list: "product-card product-card-list",
};
export default function ProductCard({
product,
layout = "grid",
horizontal = false,
}: {
product: Product;
layout?: SiteSettings["product_layout"];
horizontal?: boolean;
}) {
const imageUrl = useMediaUrl(product.primary_media_id);
const tiers = usePriceTiers(product.id);
const units = useUnits();
function unitSymbol(unitId: string) {
return units.find((u) => u.id === unitId)?.symbol ?? "";
}
const className = horizontal ? `${LAYOUT_CLASSES[layout]} product-card-carousel-item` : LAYOUT_CLASSES[layout];
const tiersList = tiers.length > 0 && (
<ul className="product-card-tiers">
{tiers.map((tier) => (
<li key={tier.id}>
<span className="product-card-tier-qty">
{tier.quantity} {unitSymbol(tier.unit_id)}
</span>
<span className="product-card-tier-price">{formatCents(tier.price_cents)}</span>
</li>
))}
</ul>
);
const image = imageUrl ? (
<img src={imageUrl} alt={product.name} />
) : (
<div className="product-card-placeholder">
<ImageIcon />
</div>
);
if (layout === "grid-overlay") {
return (
<Link to={`/products/${product.id}`} className={className}>
<div className="product-card-image">
{image}
<div className="product-card-overlay-content">
<h3>{product.name}</h3>
{tiersList}
</div>
</div>
</Link>
);
}
return (
<Link to={`/products/${product.id}`} className={className}>
<div className="product-card-image">{image}</div>
<div className="product-card-body">
<h3>{product.name}</h3>
{tiersList}
</div>
</Link>
);
}
@@ -0,0 +1,185 @@
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { apiFetch } from "../../api/client";
import { formatCents, type Category, type PriceTier, type Product, type Unit } from "../../api/types";
import { useCart } from "./CartContext";
import { useMediaUrl } from "./useMediaUrl";
import { useSiteSettings } from "./SiteSettingsContext";
import GalleryThumb from "./GalleryThumb";
import { useI18n } from "../../i18n/LanguageContext";
export default function ProductDetailPage() {
const { t } = useI18n();
const site = useSiteSettings();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { addItem } = useCart();
const [product, setProduct] = useState<Product | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [siblings, setSiblings] = useState<Product[]>([]);
const [tiers, setTiers] = useState<PriceTier[]>([]);
const [units, setUnits] = useState<Unit[]>([]);
const [gallery, setGallery] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const [selectedTierId, setSelectedTierId] = useState("");
const [multiplier, setMultiplier] = useState(1);
const [added, setAdded] = useState(false);
const primaryImage = useMediaUrl(product?.primary_media_id);
useEffect(() => {
if (!id) return;
setLoading(true);
setNotFound(false);
setAdded(false);
apiFetch<Product>(`/api/products/${id}`)
.then((p) => {
setProduct(p);
return Promise.all([
apiFetch<{ price_tiers: PriceTier[] }>(`/api/price-tiers/by-product/${p.id}`),
apiFetch<{ units: Unit[] }>("/api/units"),
apiFetch<{ gallery: { media_id: string }[] }>(`/api/product-gallery/${p.id}`),
apiFetch<{ categories: Category[] }>("/api/categories"),
apiFetch<{ products: Product[] }>(`/api/products?category_id=${p.category_id}`),
]);
})
.then(([t, u, g, c, siblingList]) => {
setTiers(t.price_tiers);
setUnits(u.units);
setGallery(g.gallery.map((item) => item.media_id));
setSelectedTierId(t.price_tiers[0]?.id ?? "");
setCategories(c.categories);
setSiblings(siblingList.products);
})
.catch(() => setNotFound(true))
.finally(() => setLoading(false));
}, [id]);
function unitSymbol(unitId: string) {
return units.find((u) => u.id === unitId)?.symbol ?? "";
}
function tierLabel(t: PriceTier) {
return `${t.quantity} ${unitSymbol(t.unit_id)}${formatCents(t.price_cents)}`;
}
function handleAddToCart() {
if (!product) return;
const tier = tiers.find((t) => t.id === selectedTierId);
if (!tier) return;
addItem({
productId: product.id,
productName: product.name,
priceTierId: tier.id,
unitLabel: `${tier.quantity} ${unitSymbol(tier.unit_id)}`,
unitPriceCents: tier.price_cents,
multiplier,
});
setAdded(true);
}
if (loading) return <div className="page-loading">{t("common.loading")}</div>;
if (notFound || !product) return <p>{t("storefront.productDetail.notFound")}</p>;
const category = categories.find((c) => c.id === product.category_id);
const siblingIndex = siblings.findIndex((p) => p.id === product.id);
const previousProduct = siblingIndex > 0 ? siblings[siblingIndex - 1] : undefined;
const nextProduct = siblingIndex >= 0 && siblingIndex < siblings.length - 1 ? siblings[siblingIndex + 1] : undefined;
return (
<div>
<p>
<Link to="/">&larr; {t("storefront.productDetail.backLink")}</Link>
</p>
<div className="product-detail">
<div>
{primaryImage && <img src={primaryImage} alt={product.name} className="product-detail-image" />}
{gallery.length > 0 && (
<div className="product-gallery">
{gallery.map((mediaId) => (
<GalleryThumb key={mediaId} mediaId={mediaId} alt={product.name} />
))}
</div>
)}
</div>
<div>
<h1>{product.name}</h1>
{category && (
<Link to={`/?category_id=${category.id}`} className="badge badge-link">
{category.name}
</Link>
)}
{product.description && <p>{product.description}</p>}
{tiers.length === 0 ? (
<p>{t("storefront.productDetail.notAvailable")}</p>
) : !(site.orders_enabled && site.customer_login_enabled) ? (
<div className="panel">
<h2>{t("storefront.productDetail.chooseQuantity")}</h2>
<ul>
{tiers.map((tier) => (
<li key={tier.id}>{tierLabel(tier)}</li>
))}
</ul>
</div>
) : (
<div className="panel">
<h2>{t("storefront.productDetail.chooseQuantity")}</h2>
<div className="field">
<select value={selectedTierId} onChange={(e) => setSelectedTierId(e.target.value)}>
{tiers.map((t) => (
<option key={t.id} value={t.id}>
{tierLabel(t)}
</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="multiplier">{t("storefront.productDetail.quantityLabel")}</label>
<input
id="multiplier"
type="number"
min="1"
value={multiplier}
onChange={(e) => setMultiplier(Math.max(1, Number(e.target.value)))}
/>
</div>
<div className="form-actions">
<button className="btn btn-primary" onClick={handleAddToCart}>
{t("storefront.productDetail.addToCart")}
</button>
{added && (
<button className="btn" onClick={() => navigate("/cart")}>
{t("storefront.productDetail.viewCart")}
</button>
)}
</div>
{added && <p>{t("storefront.productDetail.added")}</p>}
</div>
)}
</div>
</div>
{(previousProduct || nextProduct) && (
<div className="product-nav">
{previousProduct ? (
<Link to={`/products/${previousProduct.id}`} className="product-nav-link product-nav-prev">
<span className="product-nav-label"> {t("storefront.productDetail.previousProduct")}</span>
<span className="product-nav-name">{previousProduct.name}</span>
</Link>
) : (
<span />
)}
{nextProduct && (
<Link to={`/products/${nextProduct.id}`} className="product-nav-link product-nav-next">
<span className="product-nav-label">{t("storefront.productDetail.nextProduct")} </span>
<span className="product-nav-name">{nextProduct.name}</span>
</Link>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,41 @@
import { useSiteSettings } from "./SiteSettingsContext";
// Standalone placeholder homepage: just the site name/description, centered
// in a rounded, transparent panel. Not wired into any route -- swap it in
// for HomePage in App.tsx when/if this is meant to become the real "/".
export default function SimpleHomePage() {
const site = useSiteSettings();
return (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
}}
>
<div
style={{
background: "rgba(255, 255, 255, 0.08)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
border: "1px solid rgba(255, 255, 255, 0.15)",
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.25)",
borderRadius: "var(--radius, 12px)",
padding: "3rem 2.5rem",
textAlign: "center",
maxWidth: 480,
}}
>
<h1 style={{ margin: 0 }}>{site.name}</h1>
{site.description && (
<p style={{ marginTop: "1rem", marginBottom: 0, color: "var(--color-text-muted)" }}>
{site.description}
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,49 @@
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: "Shop",
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,
};
const SiteSettingsContext = createContext<SiteSettings>(DEFAULT_SETTINGS);
// Fetches the admin-configured storefront appearance (colors, product
// layout, menu style) once and shares it with the whole storefront subtree,
// so pages don't each need to fetch/know about site settings.
export function SiteSettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState<SiteSettings>(DEFAULT_SETTINGS);
useEffect(() => {
apiFetch<SiteSettings>("/api/site-settings")
.then(setSettings)
.catch(() => setSettings(DEFAULT_SETTINGS));
}, []);
return <SiteSettingsContext.Provider value={settings}>{children}</SiteSettingsContext.Provider>;
}
export function useSiteSettings(): SiteSettings {
return useContext(SiteSettingsContext);
}
@@ -0,0 +1,48 @@
import { NavLink } from "react-router-dom";
import { useCart } from "./CartContext";
import { useCustomerAuth } from "./CustomerAuthContext";
import { useSiteSettings } from "./SiteSettingsContext";
import { useI18n } from "../../i18n/LanguageContext";
import { CartIcon, HomeIcon, UserIcon, ContactIcon, InfoIcon } from "../../ui/icons";
// Phone-only persistent tab bar (see .bottom-nav in index.css) replacing
// the old hamburger dropdown -- the destinations a shopper needs are
// always one tap away, the way a native shopping app would present them.
export default function StorefrontBottomNav() {
const { t } = useI18n();
const { itemCount } = useCart();
const { customer } = useCustomerAuth();
const site = useSiteSettings();
return (
<nav className="bottom-nav">
<NavLink to="/" end className={({ isActive }) => `bottom-nav-item ${isActive ? "active" : ""}`}>
<HomeIcon />
<span>{t("layout.storefront.catalog")}</span>
</NavLink>
<NavLink to="/home" className={({ isActive }) => `bottom-nav-item ${isActive ? "active" : ""}`}>
<InfoIcon />
<span>{t("layout.storefront.home")}</span>
</NavLink>
{site.orders_enabled && site.customer_login_enabled && (
<NavLink to="/cart" className={({ isActive }) => `bottom-nav-item ${isActive ? "active" : ""}`}>
<span className="bottom-nav-icon-wrap">
<CartIcon />
{itemCount > 0 && <span className="bottom-nav-badge">{itemCount}</span>}
</span>
<span>{t("layout.storefront.cart")}</span>
</NavLink>
)}
<NavLink to="/contact" className={({ isActive }) => `bottom-nav-item ${isActive ? "active" : ""}`}>
<ContactIcon />
<span>{t("layout.storefront.contact")}</span>
</NavLink>
{site.customer_login_enabled && (
<NavLink to="/account" className={({ isActive }) => `bottom-nav-item ${isActive ? "active" : ""}`}>
<UserIcon />
<span>{customer ? customer.username : t("layout.storefront.account")}</span>
</NavLink>
)}
</nav>
);
}
@@ -0,0 +1,133 @@
import { type CSSProperties } from "react";
import { Link, Outlet, useLocation } from "react-router-dom";
import { SiteSettingsProvider, useSiteSettings } from "./SiteSettingsContext";
import { useCart } from "./CartContext";
import { useCustomerAuth } from "./CustomerAuthContext";
import { useMediaUrl } from "./useMediaUrl";
import { useI18n } from "../../i18n/LanguageContext";
import LanguageSwitcher from "../../i18n/LanguageSwitcher";
import ThemeToggle from "../../theme/ThemeToggle";
import VerificationGate from "./VerificationGate";
import StorefrontBottomNav from "./StorefrontBottomNav";
// Maps a pathname to the hero-page key the admin picks from in Appearance
// (see backend/internal/modules/site/handler.go's validHeroPages). Routes
// with no entry here (product detail, order confirmation, ...) never show
// the hero banner. Note: "/" is the catalog/category listing (HomePage);
// the actual "Home" tab in the bottom nav is "/home" (SimpleHomePage) --
// they're separate hero keys ("catalog" vs "home") since they're separate
// pages.
function heroPageKey(pathname: string): string | null {
if (pathname === "/") return "catalog";
if (pathname === "/home") return "home";
if (pathname === "/cart") return "cart";
if (pathname === "/checkout") return "checkout";
if (pathname === "/account") return "account";
if (pathname === "/contact") return "contact";
return null;
}
// Matches the DB defaults (see backend/migrations/000015_add_appearance_to_site_settings.up.sql).
// Colors still at these defaults haven't been explicitly customized by the
// admin, so we leave the --sf-* CSS vars unset for them and let the
// storefront CSS fall back to the theme tokens (--color-surface/--color-bg/
// --color-text), which is how an unbranded storefront follows the
// light/dark toggle. A color the admin *did* change is site branding and
// stays fixed regardless of the toggle.
const DEFAULT_APPEARANCE = {
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",
contact_card_bg_color: "#232323",
} as const;
function themeVar(cssVar: string, value: string, defaultValue: string): Record<string, string> {
return value === defaultValue ? {} : { [cssVar]: value };
}
function StorefrontShell() {
const site = useSiteSettings();
const { t } = useI18n();
const { itemCount } = useCart();
const { customer } = useCustomerAuth();
const location = useLocation();
const logoUrl = useMediaUrl(site.logo_media_id);
const heroUrl = useMediaUrl(site.hero_media_id);
const currentPageKey = heroPageKey(location.pathname);
const showHero = Boolean(heroUrl) && currentPageKey !== null && site.hero_pages.includes(currentPageKey);
const themeStyle = {
...themeVar("--sf-header-bg", site.header_bg_color, DEFAULT_APPEARANCE.header_bg_color),
...themeVar("--sf-header-text", site.header_text_color, DEFAULT_APPEARANCE.header_text_color),
...themeVar("--sf-body-bg", site.body_bg_color, DEFAULT_APPEARANCE.body_bg_color),
...themeVar("--sf-body-text", site.body_text_color, DEFAULT_APPEARANCE.body_text_color),
...themeVar("--sf-footer-bg", site.footer_bg_color, DEFAULT_APPEARANCE.footer_bg_color),
...themeVar("--sf-footer-text", site.footer_text_color, DEFAULT_APPEARANCE.footer_text_color),
...themeVar("--color-primary", site.accent_color, DEFAULT_APPEARANCE.accent_color),
...(site.contact_card_transparent
? { "--contact-card-bg": "transparent" }
: themeVar("--contact-card-bg", site.contact_card_bg_color, DEFAULT_APPEARANCE.contact_card_bg_color)),
} as CSSProperties;
const brandName = site.name || t("layout.storefront.brandFallback");
return (
<div className="storefront-shell" style={themeStyle}>
<header className="storefront-header">
<Link to="/" className="storefront-brand">
{logoUrl ? <img src={logoUrl} alt={brandName} className="storefront-logo" /> : brandName}
</Link>
{/* Home/Cart/Account live in the persistent bottom tab bar on
phones (StorefrontBottomNav); this row is the desktop nav. */}
<nav className="storefront-nav">
<Link to="/">{t("layout.storefront.catalog")}</Link>
{site.orders_enabled && site.customer_login_enabled && (
<Link to="/cart">
{t("layout.storefront.cart")}
{itemCount > 0 ? ` (${itemCount})` : ""}
</Link>
)}
<Link to="/contact">{t("layout.storefront.contact")}</Link>
{site.customer_login_enabled && (
<Link to="/account">{customer ? customer.username : t("layout.storefront.account")}</Link>
)}
</nav>
<div className="storefront-header-controls">
<LanguageSwitcher />
<ThemeToggle />
</div>
</header>
<div
className={showHero ? "storefront-body storefront-body-hero" : "storefront-body"}
style={showHero ? { backgroundImage: `url(${heroUrl})` } : undefined}
>
{showHero && <div className="storefront-hero-overlay" />}
<main className="storefront-content">
<VerificationGate>
<Outlet />
</VerificationGate>
</main>
</div>
<footer className="storefront-footer">
<p>
{brandName}
{site.description ? `${site.description}` : ""}
</p>
</footer>
<StorefrontBottomNav />
</div>
);
}
export default function StorefrontLayout() {
return (
<SiteSettingsProvider>
<StorefrontShell />
</SiteSettingsProvider>
);
}
@@ -0,0 +1,48 @@
import type { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { useCustomerAuth } from "./CustomerAuthContext";
import { useSiteSettings } from "./SiteSettingsContext";
import { useI18n } from "../../i18n/LanguageContext";
import VerificationPanel from "./VerificationPanel";
// Wraps every storefront route: once the admin requires identity
// verification, a logged-in customer sees nothing else -- no catalog, no
// cart, no account -- until an admin approves their submission. This is
// intentionally stricter than gating checkout alone (see
// backend orders.RequireApprovedVerificationIfNeeded): the verification
// step happens right after registration, not at the last moment during
// checkout.
export default function VerificationGate({ children }: { children: ReactNode }) {
const { t } = useI18n();
const { customer, loading, verification, verificationLoading, logout } = useCustomerAuth();
const site = useSiteSettings();
const navigate = useNavigate();
if (loading || (customer && verificationLoading)) {
return <div className="page-loading">{t("common.loading")}</div>;
}
const isGated = Boolean(customer) && site.customer_verification_required && verification?.status !== "approved";
if (isGated) {
return (
<div>
<div className="toolbar">
<h1>{t("storefront.verification.waitingTitle")}</h1>
<button
className="btn"
onClick={async () => {
await logout();
navigate("/");
}}
>
{t("storefront.account.logout")}
</button>
</div>
<VerificationPanel />
</div>
);
}
return <>{children}</>;
}
@@ -0,0 +1,82 @@
import { useRef, useState, type FormEvent } from "react";
import { customerApiUpload } from "../../api/client";
import type { CustomerVerification } from "../../api/types";
import { useCustomerAuth } from "./CustomerAuthContext";
import { useI18n } from "../../i18n/LanguageContext";
import type { MessageKey } from "../../i18n/messages";
const STATUS_KEYS: Record<CustomerVerification["status"], MessageKey> = {
pending: "storefront.verification.statusPending",
approved: "storefront.verification.statusApproved",
rejected: "storefront.verification.statusRejected",
};
// Pure submission/status UI: the decision of *whether* to show this at all
// (site.customer_verification_required + the customer's current status)
// lives in VerificationGate, which wraps the whole storefront so a pending
// customer sees this instead of any other page until an admin approves them.
export default function VerificationPanel() {
const { t } = useI18n();
const { verification, refreshVerification } = useCustomerAuth();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const frontRef = useRef<HTMLInputElement>(null);
const backRef = useRef<HTMLInputElement>(null);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
const front = frontRef.current?.files?.[0];
const back = backRef.current?.files?.[0];
if (!front || !back) return;
setSubmitting(true);
setError(null);
try {
const formData = new FormData();
formData.append("front", front);
formData.append("back", back);
await customerApiUpload<CustomerVerification>("/api/auth/customer/verification", formData);
await refreshVerification();
} catch {
setError(t("storefront.verification.submitError"));
} finally {
setSubmitting(false);
}
}
return (
<div className="panel">
<h2>{t("storefront.verification.title")}</h2>
<p style={{ color: "var(--color-text-muted)" }}>{t("storefront.verification.intro")}</p>
{error && <div className="alert alert-error">{error}</div>}
{verification && (
<p>
{t("storefront.verification.statusLabel")} <strong>{t(STATUS_KEYS[verification.status])}</strong>
{verification.admin_note && `${verification.admin_note}`}
</p>
)}
{(!verification || verification.status === "rejected") && (
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="doc-front">{t("storefront.verification.docFront")}</label>
<input id="doc-front" type="file" ref={frontRef} accept="image/jpeg,image/png" required />
</div>
<div className="field">
<label htmlFor="doc-back">{t("storefront.verification.docBack")}</label>
<input id="doc-back" type="file" ref={backRef} accept="image/jpeg,image/png" required />
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting
? t("storefront.verification.submitting")
: verification
? t("storefront.verification.resubmit")
: t("storefront.verification.submit")}
</button>
</div>
</form>
)}
</div>
);
}
@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import { apiFetch } from "../../api/client";
import type { Media } from "../../api/types";
// Public product responses only carry media *ids* (primary_media_id,
// gallery media_id) -- this resolves one to a URL via the public
// single-media lookup, with a tiny in-memory cache since the same image
// is often referenced by several cards on a page.
const cache = new Map<string, Media>();
export function useMediaUrl(mediaId?: string | null): string | undefined {
const [url, setUrl] = useState<string | undefined>(mediaId ? cache.get(mediaId)?.url : undefined);
useEffect(() => {
if (!mediaId) {
setUrl(undefined);
return;
}
const cached = cache.get(mediaId);
if (cached) {
setUrl(cached.url);
return;
}
apiFetch<Media>(`/api/media/${mediaId}`)
.then((m) => {
cache.set(mediaId, m);
setUrl(m.url);
})
.catch(() => setUrl(undefined));
}, [mediaId]);
return url;
}
@@ -0,0 +1,30 @@
import { useEffect, useState } from "react";
import { apiFetch } from "../../api/client";
import type { PriceTier } from "../../api/types";
// Product cards only get a product_id -- this resolves a product's price
// tiers via the public by-product lookup, with a tiny in-memory cache since
// the same product can appear on several listings during a session. Tiers
// come back ordered by position, so tiers[0] is "the first price" the admin
// set for this product.
const cache = new Map<string, PriceTier[]>();
export function usePriceTiers(productId: string): PriceTier[] {
const [tiers, setTiers] = useState<PriceTier[]>(cache.get(productId) ?? []);
useEffect(() => {
const cached = cache.get(productId);
if (cached) {
setTiers(cached);
return;
}
apiFetch<{ price_tiers: PriceTier[] }>(`/api/price-tiers/by-product/${productId}`)
.then((data) => {
cache.set(productId, data.price_tiers);
setTiers(data.price_tiers);
})
.catch(() => setTiers([]));
}, [productId]);
return tiers;
}
@@ -0,0 +1,26 @@
import { useEffect, useState } from "react";
import { apiFetch } from "../../api/client";
import type { Unit } from "../../api/types";
// Units are a short, rarely-changing global list -- fetched once and shared
// across every product card that needs to render a tier's unit symbol.
let cache: Unit[] | null = null;
export function useUnits(): Unit[] {
const [units, setUnits] = useState<Unit[]>(cache ?? []);
useEffect(() => {
if (cache) {
setUnits(cache);
return;
}
apiFetch<{ units: Unit[] }>("/api/units")
.then((data) => {
cache = data.units;
setUnits(data.units);
})
.catch(() => setUnits([]));
}, []);
return units;
}
+46
View File
@@ -0,0 +1,46 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { LOCALES, translate, type Locale, type MessageKey } from "./messages";
const STORAGE_KEY = "ui_locale";
function detectInitialLocale(): Locale {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && (LOCALES as string[]).includes(stored)) return stored as Locale;
const browser = navigator.language?.slice(0, 2);
if (browser && (LOCALES as string[]).includes(browser)) return browser as Locale;
return "en";
}
interface LanguageContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: MessageKey, vars?: Record<string, string | number>) => string;
}
const LanguageContext = createContext<LanguageContextValue | undefined>(undefined);
export function LanguageProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(detectInitialLocale);
const setLocale = useCallback((next: Locale) => {
setLocaleState(next);
localStorage.setItem(STORAGE_KEY, next);
}, []);
const t = useCallback(
(key: MessageKey, vars?: Record<string, string | number>) => translate(locale, key, vars),
[locale],
);
const value = useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>;
}
export function useI18n(): LanguageContextValue {
const ctx = useContext(LanguageContext);
if (!ctx) throw new Error("useI18n must be used within a LanguageProvider");
return ctx;
}
+69
View File
@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from "react";
import { LOCALES, LOCALE_LABELS, type Locale } from "./messages";
import { useI18n } from "./LanguageContext";
import { LanguageIcon } from "../ui/icons";
// Reused as-is in both the admin topbar and the storefront header. Shows
// only the globe icon at rest -- the list of languages only appears once
// the button is clicked, like a native <select> but without always
// displaying the current selection's label next to the icon.
export default function LanguageSwitcher({ className }: { className?: string }) {
const { locale, setLocale } = useI18n();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [open]);
function selectLocale(code: Locale) {
setLocale(code);
setOpen(false);
}
return (
<div className={`lang-switcher ${className ?? ""}`} ref={rootRef}>
<button
type="button"
className="lang-switcher-trigger"
onClick={() => setOpen((o) => !o)}
aria-label="Language"
aria-haspopup="listbox"
aria-expanded={open}
>
<LanguageIcon className="lang-switcher-icon" />
</button>
{open && (
<ul className="lang-switcher-menu" role="listbox">
{LOCALES.map((code) => (
<li key={code}>
<button
type="button"
role="option"
aria-selected={code === locale}
className={`lang-switcher-option ${code === locale ? "active" : ""}`}
onClick={() => selectLocale(code)}
>
{LOCALE_LABELS[code]}
</button>
</li>
))}
</ul>
)}
</div>
);
}
+470
View File
@@ -0,0 +1,470 @@
// Every UI string lives here, keyed by a dot-path and translated into all
// four supported locales side by side -- keeping the four translations of
// one string next to each other (rather than one file per language) makes
// it obvious when a language is missing an entry.
export type Locale = "en" | "fr" | "es" | "de";
export const LOCALES: Locale[] = ["en", "fr", "es", "de"];
export const LOCALE_LABELS: Record<Locale, string> = {
en: "English",
fr: "Français",
es: "Español",
de: "Deutsch",
};
type MessageEntry = Record<Locale, string>;
export const messages = {
// --- common ---
"common.save": { en: "Save", fr: "Enregistrer", es: "Guardar", de: "Speichern" },
"common.saving": { en: "Saving…", fr: "Enregistrement…", es: "Guardando…", de: "Speichern…" },
"common.cancel": { en: "Cancel", fr: "Annuler", es: "Cancelar", de: "Abbrechen" },
"common.delete": { en: "Delete", fr: "Supprimer", es: "Eliminar", de: "Löschen" },
"common.edit": { en: "Edit", fr: "Modifier", es: "Editar", de: "Bearbeiten" },
"common.view": { en: "View", fr: "Voir", es: "Ver", de: "Ansehen" },
"common.loading": { en: "Loading…", fr: "Chargement…", es: "Cargando…", de: "Wird geladen…" },
"common.active": { en: "active", fr: "actif", es: "activo", de: "aktiv" },
"common.inactive": { en: "inactive", fr: "inactif", es: "inactivo", de: "inaktiv" },
"common.none": { en: "None", fr: "Aucun", es: "Ninguno", de: "Keine" },
"common.creating": { en: "Creating…", fr: "Création…", es: "Creando…", de: "Wird erstellt…" },
"common.uploading": { en: "Uploading…", fr: "Envoi…", es: "Subiendo…", de: "Wird hochgeladen…" },
"common.moveUp": { en: "Move up", fr: "Monter", es: "Subir", de: "Nach oben" },
"common.moveDown": { en: "Move down", fr: "Descendre", es: "Bajar", de: "Nach unten" },
"common.confirmTitle": { en: "Please confirm", fr: "Merci de confirmer", es: "Por favor confirma", de: "Bitte bestätigen" },
"common.confirm": { en: "Confirm", fr: "Confirmer", es: "Confirmar", de: "Bestätigen" },
"common.savedToast": { en: "Saved", fr: "Enregistré", es: "Guardado", de: "Gespeichert" },
"common.createdToast": { en: "Created", fr: "Créé", es: "Creado", de: "Erstellt" },
"common.deletedToast": { en: "Deleted", fr: "Supprimé", es: "Eliminado", de: "Gelöscht" },
"common.name": { en: "Name", fr: "Nom", es: "Nombre", de: "Name" },
"common.description": { en: "Description", fr: "Description", es: "Descripción", de: "Beschreibung" },
"common.position": { en: "Position", fr: "Position", es: "Posición", de: "Position" },
"common.status": { en: "Status", fr: "Statut", es: "Estado", de: "Status" },
"common.logout": { en: "Log out", fr: "Déconnexion", es: "Cerrar sesión", de: "Abmelden" },
// --- admin layout ---
"layout.admin.title": { en: "Admin Panel", fr: "Panneau d'administration", es: "Panel de administración", de: "Admin-Bereich" },
"layout.admin.logout": { en: "Log out", fr: "Déconnexion", es: "Cerrar sesión", de: "Abmelden" },
"layout.admin.toggleMenu": { en: "Toggle menu", fr: "Afficher/masquer le menu", es: "Mostrar/ocultar menú", de: "Menü umschalten" },
"layout.admin.nav.site": { en: "Site", fr: "Site", es: "Sitio", de: "Website" },
"layout.admin.nav.catalog": { en: "Catalog", fr: "Catalogue", es: "Catálogo", de: "Katalog" },
"layout.admin.nav.sales": { en: "Sales", fr: "Ventes", es: "Ventas", de: "Verkauf" },
"layout.admin.nav.communication": { en: "Communication", fr: "Communication", es: "Comunicación", de: "Kommunikation" },
"layout.admin.nav.system": { en: "System", fr: "Système", es: "Sistema", de: "System" },
"layout.admin.nav.dashboard": { en: "Dashboard", fr: "Tableau de bord", es: "Panel", de: "Übersicht" },
"layout.admin.nav.general": { en: "General", fr: "Général", es: "General", de: "Allgemein" },
"layout.admin.nav.appearance": { en: "Appearance", fr: "Apparence", es: "Apariencia", de: "Erscheinungsbild" },
"layout.admin.nav.categories": { en: "Categories", fr: "Catégories", es: "Categorías", de: "Kategorien" },
"layout.admin.nav.units": { en: "Units", fr: "Unités", es: "Unidades", de: "Einheiten" },
"layout.admin.nav.products": { en: "Products", fr: "Produits", es: "Productos", de: "Produkte" },
"layout.admin.nav.media": { en: "Media", fr: "Médias", es: "Medios", de: "Medien" },
"layout.admin.nav.orders": { en: "Orders", fr: "Commandes", es: "Pedidos", de: "Bestellungen" },
"layout.admin.nav.telegram": { en: "Telegram", fr: "Telegram", es: "Telegram", de: "Telegram" },
"layout.admin.nav.contactLinks": { en: "Contact links", fr: "Liens de contact", es: "Enlaces de contacto", de: "Kontaktlinks" },
"layout.admin.nav.users": { en: "Users", fr: "Utilisateurs", es: "Usuarios", de: "Benutzer" },
"layout.admin.nav.customerAccounts": { en: "Customer accounts", fr: "Comptes clients", es: "Cuentas de clientes", de: "Kundenkonten" },
"layout.admin.nav.customerVerifications": { en: "Customer verifications", fr: "Vérifications clients", es: "Verificaciones de clientes", de: "Kundenverifizierungen" },
// --- storefront layout ---
"layout.storefront.brandFallback": { en: "Shop", fr: "Boutique", es: "Tienda", de: "Shop" },
"layout.storefront.catalog": { en: "Catalog", fr: "Catalogue", es: "Catálogo", de: "Katalog" },
"layout.storefront.home": { en: "Home", fr: "Accueil", es: "Inicio", de: "Startseite" },
"layout.storefront.cart": { en: "Cart", fr: "Panier", es: "Carrito", de: "Warenkorb" },
"layout.storefront.account": { en: "Account", fr: "Compte", es: "Cuenta", de: "Konto" },
"layout.storefront.contact": { en: "Contact", fr: "Contact", es: "Contacto", de: "Kontakt" },
"layout.storefront.toggleMenu": { en: "Toggle menu", fr: "Afficher/masquer le menu", es: "Mostrar/ocultar menú", de: "Menü umschalten" },
// --- admin login ---
"auth.login.title": { en: "Admin sign in", fr: "Connexion administrateur", es: "Acceso de administrador", de: "Admin-Anmeldung" },
"auth.login.username": { en: "Username", fr: "Identifiant", es: "Usuario", de: "Benutzername" },
"auth.login.password": { en: "Password", fr: "Mot de passe", es: "Contraseña", de: "Passwort" },
"auth.login.submit": { en: "Sign in", fr: "Se connecter", es: "Iniciar sesión", de: "Anmelden" },
"auth.login.submitting": { en: "Signing in…", fr: "Connexion…", es: "Iniciando sesión…", de: "Anmeldung läuft…" },
"auth.login.errorInvalid": { en: "Invalid username or password.", fr: "Identifiant ou mot de passe incorrect.", es: "Usuario o contraseña incorrectos.", de: "Ungültiger Benutzername oder Passwort." },
"auth.login.errorRateLimited": { en: "Too many attempts. Please wait a moment and try again.", fr: "Trop de tentatives. Merci de patienter puis réessayer.", es: "Demasiados intentos. Espera un momento y vuelve a intentarlo.", de: "Zu viele Versuche. Bitte warte kurz und versuche es erneut." },
"auth.login.errorGeneric": { en: "Something went wrong. Please try again.", fr: "Une erreur est survenue. Merci de réessayer.", es: "Algo salió mal. Inténtalo de nuevo.", de: "Etwas ist schiefgelaufen. Bitte versuche es erneut." },
// --- admin dashboard ---
"admin.dashboard.title": { en: "Dashboard", fr: "Tableau de bord", es: "Panel", de: "Übersicht" },
"admin.dashboard.signedInAs": { en: "Signed in as {{username}} ({{role}}).", fr: "Connecté en tant que {{username}} ({{role}}).", es: "Sesión iniciada como {{username}} ({{role}}).", de: "Angemeldet als {{username}} ({{role}})." },
"admin.dashboard.loadError": { en: "Failed to load some dashboard figures.", fr: "Échec du chargement de certains chiffres du tableau de bord.", es: "No se pudieron cargar algunas cifras del panel.", de: "Einige Kennzahlen konnten nicht geladen werden." },
"admin.dashboard.categoryCount": { en: "Categories", fr: "Catégories", es: "Categorías", de: "Kategorien" },
"admin.dashboard.productCount": { en: "Products", fr: "Produits", es: "Productos", de: "Produkte" },
"admin.dashboard.customerCount": { en: "Customers", fr: "Clients", es: "Clientes", de: "Kunden" },
"admin.dashboard.orderCount": { en: "Orders", fr: "Commandes", es: "Pedidos", de: "Bestellungen" },
// --- admin site settings ---
"admin.siteSettings.title": { en: "General", fr: "Général", es: "General", de: "Allgemein" },
"admin.siteSettings.loadError": { en: "Failed to load site settings.", fr: "Échec du chargement des paramètres du site.", es: "No se pudieron cargar los ajustes del sitio.", de: "Website-Einstellungen konnten nicht geladen werden." },
"admin.siteSettings.siteName": { en: "Site name", fr: "Nom du site", es: "Nombre del sitio", de: "Website-Name" },
"admin.siteSettings.description": { en: "Description", fr: "Description", es: "Descripción", de: "Beschreibung" },
"admin.siteSettings.saved": { en: "Site settings saved.", fr: "Paramètres du site enregistrés.", es: "Ajustes del sitio guardados.", de: "Website-Einstellungen gespeichert." },
"admin.siteSettings.saveError": { en: "Failed to save site settings.", fr: "Échec de l'enregistrement des paramètres du site.", es: "No se pudieron guardar los ajustes del sitio.", de: "Website-Einstellungen konnten nicht gespeichert werden." },
"admin.siteSettings.ordersEnabled": { en: "Enable shopping cart & orders (boutique mode)", fr: "Activer le panier et les commandes (mode boutique)", es: "Activar el carrito y los pedidos (modo tienda)", de: "Warenkorb und Bestellungen aktivieren (Shop-Modus)" },
"admin.siteSettings.ordersEnabledHint": { en: "Off = pure showcase site: no cart, no checkout, prices shown as indicative only.", fr: "Désactivé = site vitrine pur : pas de panier, pas de commande, les prix sont juste indicatifs.", es: "Desactivado = sitio meramente de escaparate: sin carrito, sin pago, los precios solo son indicativos.", de: "Aus = reine Showcase-Website: kein Warenkorb, kein Checkout, Preise nur zur Orientierung." },
// --- admin appearance ---
"admin.appearance.title": { en: "Appearance", fr: "Apparence", es: "Apariencia", de: "Erscheinungsbild" },
"admin.appearance.loadError": { en: "Failed to load appearance settings.", fr: "Échec du chargement des paramètres d'apparence.", es: "No se pudieron cargar los ajustes de apariencia.", de: "Erscheinungsbild-Einstellungen konnten nicht geladen werden." },
"admin.appearance.saved": { en: "Appearance saved.", fr: "Apparence enregistrée.", es: "Apariencia guardada.", de: "Erscheinungsbild gespeichert." },
"admin.appearance.saveError": { en: "Failed to save appearance settings.", fr: "Échec de l'enregistrement de l'apparence.", es: "No se pudieron guardar los ajustes de apariencia.", de: "Erscheinungsbild-Einstellungen konnten nicht gespeichert werden." },
"admin.appearance.header": { en: "Header", fr: "En-tête", es: "Cabecera", de: "Kopfbereich" },
"admin.appearance.body": { en: "Body", fr: "Corps", es: "Cuerpo", de: "Hauptbereich" },
"admin.appearance.footer": { en: "Footer", fr: "Pied de page", es: "Pie de página", de: "Fußbereich" },
"admin.appearance.accent": { en: "Accent", fr: "Accent", es: "Acento", de: "Akzent" },
"admin.appearance.contactCards": { en: "Contact link cards", fr: "Cartes des liens de contact", es: "Tarjetas de enlaces de contacto", de: "Kontaktlink-Karten" },
"admin.appearance.contactCardsHint": { en: "The cards shown on the storefront Contact page (WhatsApp, Telegram, ...).", fr: "Les cartes affichées sur la page Contact de la boutique (WhatsApp, Telegram, ...).", es: "Las tarjetas mostradas en la página de contacto de la tienda (WhatsApp, Telegram, ...).", de: "Die Karten auf der Kontaktseite des Shops (WhatsApp, Telegram, ...)." },
"admin.appearance.contactCardsTransparent": { en: "Transparent (see the page background/image through the cards)", fr: "Transparent (laisse voir le fond ou l'image de la page à travers les cartes)", es: "Transparente (deja ver el fondo o la imagen de la página a través de las tarjetas)", de: "Transparent (Hintergrund/Bild der Seite durch die Karten sichtbar)" },
"admin.appearance.layout": { en: "Product layout", fr: "Disposition des produits", es: "Disposición de los productos", de: "Produktplatzierung" },
"admin.appearance.background": { en: "Background", fr: "Arrière-plan", es: "Fondo", de: "Hintergrund" },
"admin.appearance.text": { en: "Text", fr: "Texte", es: "Texto", de: "Text" },
"admin.appearance.buttonsLinks": { en: "Buttons & links", fr: "Boutons et liens", es: "Botones y enlaces", de: "Buttons & Links" },
"admin.appearance.productDisplay": { en: "Product display", fr: "Affichage des produits", es: "Visualización de productos", de: "Produktdarstellung" },
"admin.appearance.layoutGrid": { en: "Grid (image cards)", fr: "Grille (cartes avec image)", es: "Cuadrícula (tarjetas con imagen)", de: "Raster (Bildkarten)" },
"admin.appearance.layoutGridOverlay": { en: "Grid (name & price over the image)", fr: "Grille (nom et prix sur l'image)", es: "Cuadrícula (nombre y precio sobre la imagen)", de: "Raster (Name & Preis über dem Bild)" },
"admin.appearance.layoutGridMinimal": { en: "Grid (minimal, no border)", fr: "Grille (minimaliste, sans bordure)", es: "Cuadrícula (minimalista, sin borde)", de: "Raster (minimalistisch, ohne Rand)" },
"admin.appearance.layoutList": { en: "List (rows with image on the side)", fr: "Liste (lignes avec image sur le côté)", es: "Lista (filas con imagen al lado)", de: "Liste (Zeilen mit seitlichem Bild)" },
"admin.appearance.columnsPerRow": { en: "Columns per row", fr: "Colonnes par ligne", es: "Columnas por fila", de: "Spalten pro Zeile" },
"admin.appearance.columnsAuto": { en: "Auto (fits screen size)", fr: "Automatique (selon l'écran)", es: "Automático (según la pantalla)", de: "Automatisch (je nach Bildschirm)" },
"admin.appearance.scrollDirection": { en: "Scroll direction", fr: "Sens de défilement", es: "Sentido de desplazamiento", de: "Scrollrichtung" },
"admin.appearance.scrollVertical": { en: "Vertical (normal page scroll)", fr: "Vertical (défilement classique de la page)", es: "Vertical (desplazamiento normal de la página)", de: "Vertikal (normales Seiten-Scrollen)" },
"admin.appearance.scrollHorizontal": { en: "Horizontal (swipe/scroll sideways)", fr: "Horizontal (glissement latéral)", es: "Horizontal (deslizamiento lateral)", de: "Horizontal (seitliches Wischen)" },
"admin.appearance.logo": { en: "Logo", fr: "Logo", es: "Logo", de: "Logo" },
"admin.appearance.logoHint": { en: "Shown at the top-left of the storefront header instead of the site name. Leave unset to keep the text name.", fr: "Affiché en haut à gauche du header de la boutique, à la place du nom du site en texte. Laissez vide pour garder le texte.", es: "Se muestra arriba a la izquierda del encabezado de la tienda en lugar del nombre del sitio. Déjelo vacío para conservar el texto.", de: "Wird oben links im Shop-Header anstelle des Website-Namens angezeigt. Leer lassen, um den Textnamen beizubehalten." },
"admin.appearance.hero": { en: "Background", fr: "Arrière plan", es: "Fondo", de: "Hintergrund" },
"admin.appearance.heroHint": { en: "A full-width banner image shown above the page content, on whichever pages you pick below.", fr: "Une image pleine largeur affichée au-dessus du contenu, sur les pages cochées ci-dessous.", es: "Una imagen a todo lo ancho que se muestra encima del contenido, en las páginas que marques abajo.", de: "Ein bildschirmbreites Banner über dem Seiteninhalt, auf den unten ausgewählten Seiten." },
"admin.appearance.heroPages": { en: "Show the hero banner on", fr: "Afficher la bannière sur", es: "Mostrar el banner en", de: "Banner anzeigen auf" },
"admin.appearance.pageCatalog": { en: "Catalog", fr: "Catalogue", es: "Catálogo", de: "Katalog" },
"admin.appearance.pageHome": { en: "Home", fr: "Accueil", es: "Inicio", de: "Startseite" },
"admin.appearance.pageCart": { en: "Cart", fr: "Panier", es: "Carrito", de: "Warenkorb" },
"admin.appearance.pageCheckout": { en: "Checkout", fr: "Commande", es: "Pago", de: "Kasse" },
"admin.appearance.pageAccount": { en: "Account", fr: "Compte", es: "Cuenta", de: "Konto" },
"admin.appearance.pageContact": { en: "Contact", fr: "Contact", es: "Contacto", de: "Kontakt" },
"admin.appearance.uploadImage": { en: "Upload image", fr: "Envoyer une image", es: "Subir imagen", de: "Bild hochladen" },
"admin.appearance.imageUploadError": { en: "Failed to upload image.", fr: "Échec de l'envoi de l'image.", es: "No se pudo subir la imagen.", de: "Bild konnte nicht hochgeladen werden." },
// --- admin users ---
"admin.users.title": { en: "Users", fr: "Utilisateurs", es: "Usuarios", de: "Benutzer" },
"admin.users.loadError": { en: "Failed to load users.", fr: "Échec du chargement des utilisateurs.", es: "No se pudieron cargar los usuarios.", de: "Benutzer konnten nicht geladen werden." },
"admin.users.createTitle": { en: "Create user", fr: "Créer un utilisateur", es: "Crear usuario", de: "Benutzer anlegen" },
"admin.users.username": { en: "Username", fr: "Identifiant", es: "Usuario", de: "Benutzername" },
"admin.users.password": { en: "Password", fr: "Mot de passe", es: "Contraseña", de: "Passwort" },
"admin.users.role": { en: "Role", fr: "Rôle", es: "Rol", de: "Rolle" },
"admin.users.roleAdmin": { en: "Admin", fr: "Administrateur", es: "Administrador", de: "Administrator" },
"admin.users.roleCustomer": { en: "Customer", fr: "Client", es: "Cliente", de: "Kunde" },
"admin.users.createButton": { en: "Create user", fr: "Créer l'utilisateur", es: "Crear usuario", de: "Benutzer erstellen" },
"admin.users.createError": { en: "Failed to create user. Password must be at least 12 characters or there can be only one administrator.", fr: "Échec de la création de l'utilisateur. Le mot de passe doit contenir au moins 12 caractères ou il ne peut y avoir qu'un seul administrateur.", es: "No se pudo crear el usuario. La contraseña debe tener al menos 12 caracteres o solo puede haber un administrador.", de: "Benutzer konnte nicht erstellt werden. Das Passwort muss mindestens 12 Zeichen lang sein oder es kann nur einen einzigen Administrator geben." },
"admin.users.confirmDelete": { en: "Delete this user?", fr: "Supprimer cet utilisateur ?", es: "¿Eliminar este usuario?", de: "Diesen Benutzer löschen?" },
"admin.users.deleteError": { en: "Failed to delete user.", fr: "Échec de la suppression de l'utilisateur.", es: "No se pudo eliminar el usuario.", de: "Benutzer konnte nicht gelöscht werden." },
"admin.users.colUsername": { en: "Username", fr: "Identifiant", es: "Usuario", de: "Benutzername" },
"admin.users.colRole": { en: "Role", fr: "Rôle", es: "Rol", de: "Rolle" },
"admin.users.colStatus": { en: "Status", fr: "Statut", es: "Estado", de: "Status" },
// --- admin categories ---
"admin.categories.title": { en: "Categories", fr: "Catégories", es: "Categorías", de: "Kategorien" },
"admin.categories.loadError": { en: "Failed to load categories.", fr: "Échec du chargement des catégories.", es: "No se pudieron cargar las categorías.", de: "Kategorien konnten nicht geladen werden." },
"admin.categories.editTitle": { en: "Edit category", fr: "Modifier la catégorie", es: "Editar categoría", de: "Kategorie bearbeiten" },
"admin.categories.newTitle": { en: "New category", fr: "Nouvelle catégorie", es: "Nueva categoría", de: "Neue Kategorie" },
"admin.categories.name": { en: "Name", fr: "Nom", es: "Nombre", de: "Name" },
"admin.categories.position": { en: "Position", fr: "Position", es: "Posición", de: "Position" },
"admin.categories.description": { en: "Description", fr: "Description", es: "Descripción", de: "Beschreibung" },
"admin.categories.active": { en: "Active", fr: "Actif", es: "Activo", de: "Aktiv" },
"admin.categories.image": { en: "Image", fr: "Image", es: "Imagen", de: "Bild" },
"admin.categories.saveChanges": { en: "Save changes", fr: "Enregistrer les modifications", es: "Guardar cambios", de: "Änderungen speichern" },
"admin.categories.createButton": { en: "Create category", fr: "Créer la catégorie", es: "Crear categoría", de: "Kategorie erstellen" },
"admin.categories.saveError": { en: "Failed to save category.", fr: "Échec de l'enregistrement de la catégorie.", es: "No se pudo guardar la categoría.", de: "Kategorie konnte nicht gespeichert werden." },
"admin.categories.confirmDelete": { en: "Delete this category?", fr: "Supprimer cette catégorie ?", es: "¿Eliminar esta categoría?", de: "Diese Kategorie löschen?" },
"admin.categories.deleteError": { en: "Failed to delete category (it may still have products attached).", fr: "Échec de la suppression de la catégorie (des produits y sont peut-être encore rattachés).", es: "No se pudo eliminar la categoría (puede que aún tenga productos asociados).", de: "Kategorie konnte nicht gelöscht werden (es sind möglicherweise noch Produkte zugeordnet)." },
"admin.categories.reorderError": { en: "Failed to reorder categories.", fr: "Échec de la réorganisation des catégories.", es: "No se pudo reordenar las categorías.", de: "Kategorien konnten nicht neu sortiert werden." },
"admin.categories.colStatus": { en: "Status", fr: "Statut", es: "Estado", de: "Status" },
// --- admin units ---
"admin.units.title": { en: "Units", fr: "Unités", es: "Unidades", de: "Einheiten" },
"admin.units.loadError": { en: "Failed to load units.", fr: "Échec du chargement des unités.", es: "No se pudieron cargar las unidades.", de: "Einheiten konnten nicht geladen werden." },
"admin.units.editTitle": { en: "Edit unit", fr: "Modifier l'unité", es: "Editar unidad", de: "Einheit bearbeiten" },
"admin.units.newTitle": { en: "New unit", fr: "Nouvelle unité", es: "Nueva unidad", de: "Neue Einheit" },
"admin.units.name": { en: "Name", fr: "Nom", es: "Nombre", de: "Name" },
"admin.units.symbol": { en: "Symbol", fr: "Symbole", es: "Símbolo", de: "Symbol" },
"admin.units.saveChanges": { en: "Save changes", fr: "Enregistrer les modifications", es: "Guardar cambios", de: "Änderungen speichern" },
"admin.units.createButton": { en: "Create unit", fr: "Créer l'unité", es: "Crear unidad", de: "Einheit erstellen" },
"admin.units.saveError": { en: "Failed to save unit (symbol may already be in use).", fr: "Échec de l'enregistrement de l'unité (le symbole est peut-être déjà utilisé).", es: "No se pudo guardar la unidad (el símbolo puede que ya esté en uso).", de: "Einheit konnte nicht gespeichert werden (Symbol wird möglicherweise bereits verwendet)." },
"admin.units.confirmDelete": { en: "Delete this unit?", fr: "Supprimer cette unité ?", es: "¿Eliminar esta unidad?", de: "Diese Einheit löschen?" },
"admin.units.deleteError": { en: "Failed to delete unit (it may still be used by price tiers).", fr: "Échec de la suppression de l'unité (elle est peut-être encore utilisée par des paliers de prix).", es: "No se pudo eliminar la unidad (puede que aún se use en niveles de precio).", de: "Einheit konnte nicht gelöscht werden (wird möglicherweise noch in Preisstaffeln verwendet)." },
// --- admin products ---
"admin.products.title": { en: "Products", fr: "Produits", es: "Productos", de: "Produkte" },
"admin.products.loadError": { en: "Failed to load products.", fr: "Échec du chargement des produits.", es: "No se pudieron cargar los productos.", de: "Produkte konnten nicht geladen werden." },
"admin.products.newTitle": { en: "New product", fr: "Nouveau produit", es: "Nuevo producto", de: "Neues Produkt" },
"admin.products.noCategoriesPrefix": { en: "No categories yet.", fr: "Aucune catégorie pour l'instant.", es: "Aún no hay categorías.", de: "Noch keine Kategorien vorhanden." },
"admin.products.noCategoriesLink": { en: "Create a category", fr: "Créez une catégorie", es: "Crea una categoría", de: "Kategorie erstellen" },
"admin.products.noCategoriesSuffix": { en: "before adding products.", fr: "avant d'ajouter des produits.", es: "antes de añadir productos.", de: "bevor du Produkte hinzufügst." },
"admin.products.name": { en: "Name", fr: "Nom", es: "Nombre", de: "Name" },
"admin.products.description": { en: "Description", fr: "Description", es: "Descripción", de: "Beschreibung" },
"admin.products.category": { en: "Category", fr: "Catégorie", es: "Categoría", de: "Kategorie" },
"admin.products.createButton": { en: "Create product", fr: "Créer le produit", es: "Crear producto", de: "Produkt erstellen" },
"admin.products.missingCategory": { en: "Create a category first — a product must belong to one.", fr: "Créez d'abord une catégorie — un produit doit obligatoirement en avoir une.", es: "Crea primero una categoría — un producto debe pertenecer a una.", de: "Erstelle zuerst eine Kategorie — ein Produkt muss einer zugeordnet sein." },
"admin.products.createError": { en: "Failed to create product (slug may already be in use).", fr: "Échec de la création du produit (le slug est peut-être déjà utilisé).", es: "No se pudo crear el producto (el slug puede que ya esté en uso).", de: "Produkt konnte nicht erstellt werden (Slug wird möglicherweise bereits verwendet)." },
"admin.products.confirmDelete": { en: "Delete this product?", fr: "Supprimer ce produit ?", es: "¿Eliminar este producto?", de: "Dieses Produkt löschen?" },
"admin.products.deleteError": { en: "Failed to delete product.", fr: "Échec de la suppression du produit.", es: "No se pudo eliminar el producto.", de: "Produkt konnte nicht gelöscht werden." },
"admin.products.reorderError": { en: "Failed to reorder products.", fr: "Échec de la réorganisation des produits.", es: "No se pudo reordenar los productos.", de: "Produkte konnten nicht neu sortiert werden." },
"admin.products.colName": { en: "Name", fr: "Nom", es: "Nombre", de: "Name" },
"admin.products.colCategory": { en: "Category", fr: "Catégorie", es: "Categoría", de: "Kategorie" },
"admin.products.colOrder": { en: "Order", fr: "Ordre", es: "Orden", de: "Reihenfolge" },
"admin.products.colStatus": { en: "Status", fr: "Statut", es: "Estado", de: "Status" },
"admin.products.colFeatured": { en: "Featured", fr: "Mis en avant", es: "Destacado", de: "Hervorgehoben" },
// --- admin product edit ---
"admin.productEdit.backLink": { en: "Back to products", fr: "Retour aux produits", es: "Volver a productos", de: "Zurück zu den Produkten" },
"admin.productEdit.loadError": { en: "Failed to load product.", fr: "Échec du chargement du produit.", es: "No se pudo cargar el producto.", de: "Produkt konnte nicht geladen werden." },
"admin.productEdit.saved": { en: "Product saved.", fr: "Produit enregistré.", es: "Producto guardado.", de: "Produkt gespeichert." },
"admin.productEdit.saveError": { en: "Failed to save product (slug may already be in use).", fr: "Échec de l'enregistrement du produit (le slug est peut-être déjà utilisé).", es: "No se pudo guardar el producto (el slug puede que ya esté en uso).", de: "Produkt konnte nicht gespeichert werden (Slug wird möglicherweise bereits verwendet)." },
"admin.productEdit.addTierError": { en: "Failed to add price tier.", fr: "Échec de l'ajout du palier de prix.", es: "No se pudo añadir el nivel de precio.", de: "Preisstaffel konnte nicht hinzugefügt werden." },
"admin.productEdit.confirmDeleteTier": { en: "Delete this price tier?", fr: "Supprimer ce palier de prix ?", es: "¿Eliminar este nivel de precio?", de: "Diese Preisstaffel löschen?" },
"admin.productEdit.deleteTierError": { en: "Failed to delete price tier.", fr: "Échec de la suppression du palier de prix.", es: "No se pudo eliminar el nivel de precio.", de: "Preisstaffel konnte nicht gelöscht werden." },
"admin.productEdit.galleryError": { en: "Failed to update gallery.", fr: "Échec de la mise à jour de la galerie.", es: "No se pudo actualizar la galería.", de: "Galerie konnte nicht aktualisiert werden." },
"admin.productEdit.detailsTitle": { en: "Details", fr: "Détails", es: "Detalles", de: "Details" },
"admin.productEdit.name": { en: "Name", fr: "Nom", es: "Nombre", de: "Name" },
"admin.productEdit.category": { en: "Category", fr: "Catégorie", es: "Categoría", de: "Kategorie" },
"admin.productEdit.description": { en: "Description", fr: "Description", es: "Descripción", de: "Beschreibung" },
"admin.productEdit.active": { en: "Active", fr: "Actif", es: "Activo", de: "Aktiv" },
"admin.productEdit.featured": { en: "Featured", fr: "Mis en avant", es: "Destacado", de: "Hervorgehoben" },
"admin.productEdit.pricingTitle": { en: "Quantity-based pricing", fr: "Tarification par quantité", es: "Precios por cantidad", de: "Mengenbasierte Preise" },
"admin.productEdit.quantity": { en: "Quantity", fr: "Quantité", es: "Cantidad", de: "Menge" },
"admin.productEdit.unit": { en: "Unit", fr: "Unité", es: "Unidad", de: "Einheit" },
"admin.productEdit.price": { en: "Price", fr: "Prix", es: "Precio", de: "Preis" },
"admin.productEdit.addTier": { en: "Add price tier", fr: "Ajouter un palier de prix", es: "Añadir nivel de precio", de: "Preisstaffel hinzufügen" },
"admin.productEdit.galleryTitle": { en: "Gallery", fr: "Galerie", es: "Galería", de: "Galerie" },
"admin.productEdit.galleryHint": { en: "Upload a new photo/video below, or click an existing media item to attach/detach it from this product's gallery.", fr: "Envoyez une nouvelle photo/vidéo ci-dessous, ou cliquez sur un média existant pour l'ajouter ou le retirer de la galerie de ce produit.", es: "Sube una nueva foto/vídeo abajo, o haz clic en un archivo existente para añadirlo o quitarlo de la galería de este producto.", de: "Lade unten ein neues Foto/Video hoch oder klicke auf ein vorhandenes Medium, um es der Galerie dieses Produkts hinzuzufügen oder daraus zu entfernen." },
"admin.productEdit.uploadLabel": { en: "Photo or video", fr: "Photo ou vidéo", es: "Foto o vídeo", de: "Foto oder Video" },
"admin.productEdit.uploadButton": { en: "Upload & add to gallery", fr: "Envoyer et ajouter à la galerie", es: "Subir y añadir a la galería", de: "Hochladen und zur Galerie hinzufügen" },
"admin.productEdit.uploadError": { en: "Upload failed (check file type/size).", fr: "Échec de l'envoi (vérifiez le type et la taille du fichier).", es: "Fallo al subir (comprueba el tipo y tamaño del archivo).", de: "Upload fehlgeschlagen (Dateityp/-größe prüfen)." },
"admin.productEdit.setPrimary": { en: "Set as primary", fr: "Définir comme principale", es: "Definir como principal", de: "Als Hauptbild festlegen" },
"admin.productEdit.isPrimary": { en: "★ Primary", fr: "★ Principale", es: "★ Principal", de: "★ Hauptbild" },
// --- admin media (reused by the inline gallery uploader in productEdit) ---
"admin.media.confirmDelete": { en: "Delete this media file? This cannot be undone.", fr: "Supprimer ce média ? Cette action est irréversible.", es: "¿Eliminar este archivo multimedia? Esta acción no se puede deshacer.", de: "Diese Mediendatei löschen? Dies kann nicht rückgängig gemacht werden." },
"admin.media.deleteError": { en: "Failed to delete media.", fr: "Échec de la suppression du média.", es: "No se pudo eliminar el archivo multimedia.", de: "Medium konnte nicht gelöscht werden." },
// --- admin orders ---
"admin.orders.title": { en: "Orders", fr: "Commandes", es: "Pedidos", de: "Bestellungen" },
"admin.orders.loadError": { en: "Failed to load orders.", fr: "Échec du chargement des commandes.", es: "No se pudieron cargar los pedidos.", de: "Bestellungen konnten nicht geladen werden." },
"admin.orders.colCustomer": { en: "Customer", fr: "Client", es: "Cliente", de: "Kunde" },
"admin.orders.colEmail": { en: "Email", fr: "E-mail", es: "Correo electrónico", de: "E-Mail" },
"admin.orders.colTotal": { en: "Total", fr: "Total", es: "Total", de: "Gesamt" },
"admin.orders.view": { en: "View", fr: "Voir", es: "Ver", de: "Ansehen" },
// --- admin order detail ---
"admin.orderDetail.backLink": { en: "Back to orders", fr: "Retour aux commandes", es: "Volver a pedidos", de: "Zurück zu den Bestellungen" },
"admin.orderDetail.title": { en: "Order {{id}}", fr: "Commande {{id}}", es: "Pedido {{id}}", de: "Bestellung {{id}}" },
"admin.orderDetail.loadError": { en: "Failed to load order.", fr: "Échec du chargement de la commande.", es: "No se pudo cargar el pedido.", de: "Bestellung konnte nicht geladen werden." },
"admin.orderDetail.notFound": { en: "Order not found.", fr: "Commande introuvable.", es: "Pedido no encontrado.", de: "Bestellung nicht gefunden." },
"admin.orderDetail.customerTitle": { en: "Customer", fr: "Client", es: "Cliente", de: "Kunde" },
"admin.orderDetail.notes": { en: "Notes:", fr: "Notes :", es: "Notas:", de: "Notizen:" },
"admin.orderDetail.itemsTitle": { en: "Items", fr: "Articles", es: "Artículos", de: "Positionen" },
"admin.orderDetail.colProduct": { en: "Product", fr: "Produit", es: "Producto", de: "Produkt" },
"admin.orderDetail.colQuantity": { en: "Quantity", fr: "Quantité", es: "Cantidad", de: "Menge" },
"admin.orderDetail.colMultiplier": { en: "Multiplier", fr: "Multiplicateur", es: "Multiplicador", de: "Multiplikator" },
"admin.orderDetail.colUnitPrice": { en: "Unit price", fr: "Prix unitaire", es: "Precio unitario", de: "Stückpreis" },
"admin.orderDetail.colTotal": { en: "Total", fr: "Total", es: "Total", de: "Gesamt" },
"admin.orderDetail.total": { en: "Total: {{amount}}", fr: "Total : {{amount}}", es: "Total: {{amount}}", de: "Gesamt: {{amount}}" },
// --- admin telegram ---
"admin.telegram.title": { en: "Telegram notifications", fr: "Notifications Telegram", es: "Notificaciones de Telegram", de: "Telegram-Benachrichtigungen" },
"admin.telegram.loadError": { en: "Failed to load Telegram settings.", fr: "Échec du chargement des paramètres Telegram.", es: "No se pudieron cargar los ajustes de Telegram.", de: "Telegram-Einstellungen konnten nicht geladen werden." },
"admin.telegram.saved": { en: "Telegram settings saved.", fr: "Paramètres Telegram enregistrés.", es: "Ajustes de Telegram guardados.", de: "Telegram-Einstellungen gespeichert." },
"admin.telegram.saveError": { en: "Failed to save Telegram settings.", fr: "Échec de l'enregistrement des paramètres Telegram.", es: "No se pudieron guardar los ajustes de Telegram.", de: "Telegram-Einstellungen konnten nicht gespeichert werden." },
"admin.telegram.testSent": { en: "Test message sent.", fr: "Message de test envoyé.", es: "Mensaje de prueba enviado.", de: "Testnachricht gesendet." },
"admin.telegram.testError": { en: "Failed to send test message. Check bot token/chat id.", fr: "Échec de l'envoi du message de test. Vérifiez le jeton du bot et l'ID de discussion.", es: "No se pudo enviar el mensaje de prueba. Comprueba el token del bot y el chat id.", de: "Testnachricht konnte nicht gesendet werden. Bot-Token/Chat-ID prüfen." },
"admin.telegram.enabled": { en: "Enabled", fr: "Activé", es: "Activado", de: "Aktiviert" },
"admin.telegram.botToken": { en: "Bot token", fr: "Jeton du bot", es: "Token del bot", de: "Bot-Token" },
"admin.telegram.botTokenConfigured": { en: "(configured — leave blank to keep it)", fr: "(configuré — laissez vide pour le conserver)", es: "(configurado — déjalo en blanco para mantenerlo)", de: "(konfiguriert — leer lassen, um es beizubehalten)" },
"admin.telegram.chatId": { en: "Chat ID", fr: "ID de discussion", es: "ID de chat", de: "Chat-ID" },
"admin.telegram.notifyNewOrder": { en: "Notify on new order", fr: "Notifier à chaque nouvelle commande", es: "Notificar en cada nuevo pedido", de: "Bei neuer Bestellung benachrichtigen" },
"admin.telegram.sendTest": { en: "Send test message", fr: "Envoyer un message de test", es: "Enviar mensaje de prueba", de: "Testnachricht senden" },
"admin.telegram.sending": { en: "Sending…", fr: "Envoi…", es: "Enviando…", de: "Wird gesendet…" },
// --- admin contact links ---
"admin.contactLinks.title": { en: "Contact links", fr: "Liens de contact", es: "Enlaces de contacto", de: "Kontaktlinks" },
"admin.contactLinks.loadError": { en: "Failed to load contact links.", fr: "Échec du chargement des liens de contact.", es: "No se pudieron cargar los enlaces de contacto.", de: "Kontaktlinks konnten nicht geladen werden." },
"admin.contactLinks.editTitle": { en: "Edit contact link", fr: "Modifier le lien de contact", es: "Editar enlace de contacto", de: "Kontaktlink bearbeiten" },
"admin.contactLinks.newTitle": { en: "New contact link", fr: "Nouveau lien de contact", es: "Nuevo enlace de contacto", de: "Neuer Kontaktlink" },
"admin.contactLinks.label": { en: "Label", fr: "Libellé", es: "Etiqueta", de: "Bezeichnung" },
"admin.contactLinks.url": { en: "URL", fr: "URL", es: "URL", de: "URL" },
"admin.contactLinks.position": { en: "Position", fr: "Position", es: "Posición", de: "Position" },
"admin.contactLinks.color": { en: "Color", fr: "Couleur", es: "Color", de: "Farbe" },
"admin.contactLinks.brandIcon": { en: "Brand icon", fr: "Icône de marque", es: "Icono de marca", de: "Marken-Icon" },
"admin.contactLinks.customIcon": { en: "Custom icon", fr: "Icône personnalisée", es: "Icono personalizado", de: "Eigenes Icon" },
"admin.contactLinks.customIconHint": { en: "Not in the list above? Upload a custom icon image below.", fr: "Absente de la liste ci-dessus ? Envoyez une image d'icône personnalisée ci-dessous.", es: "¿No está en la lista? Sube una imagen de icono personalizada abajo.", de: "Nicht in der Liste oben? Lade unten ein eigenes Icon-Bild hoch." },
"admin.contactLinks.uploadIcon": { en: "Upload icon", fr: "Envoyer une icône", es: "Subir icono", de: "Icon hochladen" },
"admin.contactLinks.iconUploadError": { en: "Upload failed (check file type/size).", fr: "Échec de l'envoi (vérifiez le type et la taille du fichier).", es: "Fallo al subir (comprueba el tipo y tamaño del archivo).", de: "Upload fehlgeschlagen (Dateityp/-größe prüfen)." },
"admin.contactLinks.none": { en: "None", fr: "Aucune", es: "Ninguno", de: "Keins" },
"admin.contactLinks.active": { en: "Active", fr: "Actif", es: "Activo", de: "Aktiv" },
"admin.contactLinks.saveChanges": { en: "Save changes", fr: "Enregistrer les modifications", es: "Guardar cambios", de: "Änderungen speichern" },
"admin.contactLinks.createButton": { en: "Create contact link", fr: "Créer le lien de contact", es: "Crear enlace de contacto", de: "Kontaktlink erstellen" },
"admin.contactLinks.saveError": { en: "Failed to save contact link.", fr: "Échec de l'enregistrement du lien de contact.", es: "No se pudo guardar el enlace de contacto.", de: "Kontaktlink konnte nicht gespeichert werden." },
"admin.contactLinks.confirmDelete": { en: "Delete this contact link?", fr: "Supprimer ce lien de contact ?", es: "¿Eliminar este enlace de contacto?", de: "Diesen Kontaktlink löschen?" },
"admin.contactLinks.deleteError": { en: "Failed to delete contact link.", fr: "Échec de la suppression du lien de contact.", es: "No se pudo eliminar el enlace de contacto.", de: "Kontaktlink konnte nicht gelöscht werden." },
"admin.contactLinks.reorderError": { en: "Failed to reorder contact links.", fr: "Échec de la réorganisation des liens de contact.", es: "No se pudo reordenar los enlaces de contacto.", de: "Kontaktlinks konnten nicht neu sortiert werden." },
"admin.contactLinks.colIcon": { en: "Icon", fr: "Icône", es: "Icono", de: "Icon" },
"admin.contactLinks.colLabel": { en: "Label", fr: "Libellé", es: "Etiqueta", de: "Bezeichnung" },
"admin.contactLinks.colUrl": { en: "URL", fr: "URL", es: "URL", de: "URL" },
"admin.contactLinks.colColor": { en: "Color", fr: "Couleur", es: "Color", de: "Farbe" },
"admin.contactLinks.colPosition": { en: "Position", fr: "Position", es: "Posición", de: "Position" },
"admin.contactLinks.colStatus": { en: "Status", fr: "Statut", es: "Estado", de: "Status" },
// --- admin customer accounts ---
"admin.customerAccounts.title": { en: "Customer accounts", fr: "Comptes clients", es: "Cuentas de clientes", de: "Kundenkonten" },
"admin.customerAccounts.loadError": { en: "Failed to load customer accounts settings.", fr: "Échec du chargement des paramètres des comptes clients.", es: "No se pudieron cargar los ajustes de cuentas de clientes.", de: "Kundenkonto-Einstellungen konnten nicht geladen werden." },
"admin.customerAccounts.saved": { en: "Customer accounts settings saved.", fr: "Paramètres des comptes clients enregistrés.", es: "Ajustes de cuentas de clientes guardados.", de: "Kundenkonto-Einstellungen gespeichert." },
"admin.customerAccounts.saveError": { en: "Failed to save customer accounts settings.", fr: "Échec de l'enregistrement des paramètres des comptes clients.", es: "No se pudieron guardar los ajustes de cuentas de clientes.", de: "Kundenkonto-Einstellungen konnten nicht gespeichert werden." },
"admin.customerAccounts.loginEnabled": { en: "Enable customer login (also required to check out)", fr: "Activer la connexion client (également requise pour commander)", es: "Activar el inicio de sesión de clientes (también necesario para pagar)", de: "Kundenlogin aktivieren (auch für den Checkout erforderlich)" },
"admin.customerAccounts.registrationEnabled": { en: "Enable self-service registration (new customers can sign up)", fr: "Activer l'inscription en libre-service (les nouveaux clients peuvent s'inscrire)", es: "Activar el registro autoservicio (los nuevos clientes pueden registrarse)", de: "Selbstregistrierung aktivieren (neue Kunden können sich registrieren)" },
"admin.customerAccounts.verificationRequired": { en: "Also require an admin-approved identity verification before ordering", fr: "Exiger également une vérification d'identité approuvée par l'admin avant de commander", es: "Exigir también una verificación de identidad aprobada por el administrador antes de pedir", de: "Vor der Bestellung zusätzlich eine von der Administration genehmigte Identitätsprüfung verlangen" },
"admin.customerAccounts.contactLabel": { en: "Contact channel to show customers for verification help (optional)", fr: "Canal de contact à afficher aux clients pour les aider avec la vérification (facultatif)", es: "Canal de contacto que se mostrará a los clientes para ayudarles con la verificación (opcional)", de: "Kontaktkanal, der Kunden bei der Verifizierung angezeigt wird (optional)" },
"admin.customerAccounts.none": { en: "None", fr: "Aucun", es: "Ninguno", de: "Keiner" },
"admin.customerAccounts.noContactLinksPrefix": { en: "No contact links configured yet.", fr: "Aucun lien de contact configuré pour l'instant.", es: "Aún no hay enlaces de contacto configurados.", de: "Noch keine Kontaktlinks konfiguriert." },
"admin.customerAccounts.noContactLinksLink": { en: "Add one", fr: "En ajouter un", es: "Añadir uno", de: "Einen hinzufügen" },
"admin.customerAccounts.reviewHint": { en: "Review submitted identity documents in", fr: "Consultez les documents d'identité soumis dans", es: "Revisa los documentos de identidad enviados en", de: "Eingereichte Ausweisdokumente findest du unter" },
"admin.customerAccounts.reviewLink": { en: "Customer verifications", fr: "Vérifications clients", es: "Verificaciones de clientes", de: "Kundenverifizierungen" },
// --- admin customer verifications ---
"admin.customerVerifications.title": { en: "Customer verifications", fr: "Vérifications clients", es: "Verificaciones de clientes", de: "Kundenverifizierungen" },
"admin.customerVerifications.loadError": { en: "Failed to load verifications.", fr: "Échec du chargement des vérifications.", es: "No se pudieron cargar las verificaciones.", de: "Verifizierungen konnten nicht geladen werden." },
"admin.customerVerifications.allStatuses": { en: "All statuses", fr: "Tous les statuts", es: "Todos los estados", de: "Alle Status" },
"admin.customerVerifications.none": { en: "No submissions.", fr: "Aucune soumission.", es: "Sin envíos.", de: "Keine Einreichungen." },
"admin.customerVerifications.colUserId": { en: "User ID", fr: "ID utilisateur", es: "ID de usuario", de: "Benutzer-ID" },
"admin.customerVerifications.colStatus": { en: "Status", fr: "Statut", es: "Estado", de: "Status" },
"admin.customerVerifications.colSubmitted": { en: "Submitted", fr: "Soumis le", es: "Enviado", de: "Eingereicht" },
"admin.customerVerifications.review": { en: "Review", fr: "Examiner", es: "Revisar", de: "Prüfen" },
"admin.customerVerifications.adminNote": { en: "Admin note", fr: "Note de l'administrateur", es: "Nota del administrador", de: "Admin-Notiz" },
"admin.customerVerifications.approve": { en: "Approve", fr: "Approuver", es: "Aprobar", de: "Genehmigen" },
"admin.customerVerifications.reject": { en: "Reject", fr: "Rejeter", es: "Rechazar", de: "Ablehnen" },
"admin.customerVerifications.reviewError": { en: "Failed to save the review.", fr: "Échec de l'enregistrement de la revue.", es: "No se pudo guardar la revisión.", de: "Bewertung konnte nicht gespeichert werden." },
"admin.customerVerifications.front": { en: "front", fr: "recto", es: "anverso", de: "Vorderseite" },
"admin.customerVerifications.back": { en: "back", fr: "verso", es: "reverso", de: "Rückseite" },
"admin.customerVerifications.disabledHint": { en: "Identity verification is currently disabled. Turn on \"Also require an admin-approved identity verification before ordering\" in Customer accounts to use this page.", fr: "La vérification d'identité est actuellement désactivée. Activez « Exiger également une vérification d'identité approuvée par l'admin avant de commander » dans Comptes clients pour utiliser cette page.", es: "La verificación de identidad está desactivada. Activa «Exigir también una verificación de identidad aprobada por el administrador antes de pedir» en Cuentas de clientes para usar esta página.", de: "Die Identitätsprüfung ist derzeit deaktiviert. Aktiviere „Vor der Bestellung zusätzlich eine genehmigte Identitätsprüfung verlangen“ unter Kundenkonten, um diese Seite zu nutzen." },
"admin.customerVerifications.statusPending": { en: "pending", fr: "en attente", es: "pendiente", de: "ausstehend" },
"admin.customerVerifications.statusApproved": { en: "approved", fr: "approuvée", es: "aprobada", de: "genehmigt" },
"admin.customerVerifications.statusRejected": { en: "rejected", fr: "rejetée", es: "rechazada", de: "abgelehnt" },
// --- storefront home ---
"storefront.home.noProducts": { en: "No products available yet.", fr: "Aucun produit disponible pour l'instant.", es: "Aún no hay productos disponibles.", de: "Noch keine Produkte verfügbar." },
"storefront.home.noCategories": { en: "No categories available yet.", fr: "Aucune catégorie disponible pour l'instant.", es: "Aún no hay categorías disponibles.", de: "Noch keine Kategorien verfügbar." },
"storefront.home.backToCategories": { en: "Back to categories", fr: "Retour aux catégories", es: "Volver a las categorías", de: "Zurück zu den Kategorien" },
"storefront.home.productCount": { en: "{{count}} product", fr: "{{count}} produit", es: "{{count}} producto", de: "{{count}} Produkt" },
"storefront.home.productCountPlural": { en: "{{count}} products", fr: "{{count}} produits", es: "{{count}} productos", de: "{{count}} Produkte" },
// --- storefront contact page ---
"storefront.contact.title": { en: "Contact", fr: "Contact", es: "Contacto", de: "Kontakt" },
"storefront.contact.empty": { en: "No contact information available yet.", fr: "Aucune information de contact disponible pour l'instant.", es: "Aún no hay información de contacto disponible.", de: "Noch keine Kontaktinformationen verfügbar." },
// --- storefront product detail ---
"storefront.productDetail.backLink": { en: "Back to catalog", fr: "Retour au catalogue", es: "Volver al catálogo", de: "Zurück zum Katalog" },
"storefront.productDetail.previousProduct": { en: "Previous", fr: "Précédent", es: "Anterior", de: "Vorheriges" },
"storefront.productDetail.nextProduct": { en: "Next", fr: "Suivant", es: "Siguiente", de: "Nächstes" },
"storefront.productDetail.notFound": { en: "Product not found.", fr: "Produit introuvable.", es: "Producto no encontrado.", de: "Produkt nicht gefunden." },
"storefront.productDetail.chooseQuantity": { en: "Choose a quantity", fr: "Choisissez une quantité", es: "Elige una cantidad", de: "Menge wählen" },
"storefront.productDetail.quantityLabel": { en: "Quantity", fr: "Quantité", es: "Cantidad", de: "Menge" },
"storefront.productDetail.addToCart": { en: "Add to cart", fr: "Ajouter au panier", es: "Añadir al carrito", de: "In den Warenkorb" },
"storefront.productDetail.viewCart": { en: "View cart", fr: "Voir le panier", es: "Ver carrito", de: "Warenkorb ansehen" },
"storefront.productDetail.added": { en: "Added to cart.", fr: "Ajouté au panier.", es: "Añadido al carrito.", de: "In den Warenkorb gelegt." },
"storefront.productDetail.notAvailable": { en: "Not available for order yet.", fr: "Pas encore disponible à la commande.", es: "Aún no disponible para pedir.", de: "Noch nicht bestellbar." },
// --- storefront cart ---
"storefront.cart.title": { en: "Cart", fr: "Panier", es: "Carrito", de: "Warenkorb" },
"storefront.cart.empty": { en: "Your cart is empty.", fr: "Votre panier est vide.", es: "Tu carrito está vacío.", de: "Dein Warenkorb ist leer." },
"storefront.cart.browseCatalog": { en: "Browse the catalog", fr: "Parcourir le catalogue", es: "Explorar el catálogo", de: "Katalog durchsuchen" },
"storefront.cart.colProduct": { en: "Product", fr: "Produit", es: "Producto", de: "Produkt" },
"storefront.cart.colOption": { en: "Option", fr: "Option", es: "Opción", de: "Option" },
"storefront.cart.colQuantity": { en: "Quantity", fr: "Quantité", es: "Cantidad", de: "Menge" },
"storefront.cart.colSubtotal": { en: "Subtotal", fr: "Sous-total", es: "Subtotal", de: "Zwischensumme" },
"storefront.cart.remove": { en: "Remove", fr: "Retirer", es: "Quitar", de: "Entfernen" },
"storefront.cart.total": { en: "Total: {{amount}}", fr: "Total : {{amount}}", es: "Total: {{amount}}", de: "Gesamt: {{amount}}" },
"storefront.cart.checkout": { en: "Checkout", fr: "Commander", es: "Pagar", de: "Zur Kasse" },
// --- storefront checkout ---
"storefront.checkout.title": { en: "Checkout", fr: "Commande", es: "Pago", de: "Kasse" },
"storefront.checkout.emptyCart": { en: "Your cart is empty.", fr: "Votre panier est vide.", es: "Tu carrito está vacío.", de: "Dein Warenkorb ist leer." },
"storefront.checkout.browseCatalog": { en: "Browse the catalog", fr: "Parcourir le catalogue", es: "Explorar el catálogo", de: "Katalog durchsuchen" },
"storefront.ordersDisabled": { en: "Ordering is not available on this site.", fr: "La commande n'est pas disponible sur ce site.", es: "Los pedidos no están disponibles en este sitio.", de: "Bestellungen sind auf dieser Website nicht möglich." },
"storefront.checkout.loginRequiredPrefix": { en: "This shop requires an account to check out.", fr: "Cette boutique nécessite un compte pour commander.", es: "Esta tienda requiere una cuenta para pagar.", de: "Dieser Shop erfordert ein Konto für den Checkout." },
"storefront.checkout.loginRequiredLink": { en: "Sign in or create one", fr: "Connectez-vous ou créez-en un", es: "Inicia sesión o crea una", de: "Anmelden oder eins erstellen" },
"storefront.checkout.loginRequiredSuffix": { en: ", then come back to checkout.", fr: ", puis revenez commander.", es: ", y luego vuelve a pagar.", de: ", und kehre dann zur Kasse zurück." },
"storefront.checkout.fullName": { en: "Full name", fr: "Nom complet", es: "Nombre completo", de: "Vollständiger Name" },
"storefront.checkout.email": { en: "Email", fr: "E-mail", es: "Correo electrónico", de: "E-Mail" },
"storefront.checkout.phone": { en: "Phone", fr: "Téléphone", es: "Teléfono", de: "Telefon" },
"storefront.checkout.notes": { en: "Notes", fr: "Notes", es: "Notas", de: "Notizen" },
"storefront.checkout.total": { en: "Total: {{amount}}", fr: "Total : {{amount}}", es: "Total: {{amount}}", de: "Gesamt: {{amount}}" },
"storefront.checkout.submit": { en: "Place order", fr: "Valider la commande", es: "Realizar pedido", de: "Bestellung aufgeben" },
"storefront.checkout.submitting": { en: "Placing order…", fr: "Validation de la commande…", es: "Realizando pedido…", de: "Bestellung wird aufgegeben…" },
"storefront.checkout.genericError": { en: "Failed to place order. Please check your details and try again.", fr: "Échec de la commande. Vérifiez vos informations et réessayez.", es: "No se pudo realizar el pedido. Comprueba tus datos e inténtalo de nuevo.", de: "Bestellung fehlgeschlagen. Bitte Angaben prüfen und erneut versuchen." },
// --- storefront order confirmation ---
"storefront.orderConfirmation.title": { en: "Order confirmed", fr: "Commande confirmée", es: "Pedido confirmado", de: "Bestellung bestätigt" },
"storefront.orderConfirmation.thanks": { en: "Thanks, {{name}} — your order has been received.", fr: "Merci, {{name}} — votre commande a bien été reçue.", es: "Gracias, {{name}} — hemos recibido tu pedido.", de: "Danke, {{name}} — deine Bestellung ist eingegangen." },
"storefront.orderConfirmation.colProduct": { en: "Product", fr: "Produit", es: "Producto", de: "Produkt" },
"storefront.orderConfirmation.colOption": { en: "Option", fr: "Option", es: "Opción", de: "Option" },
"storefront.orderConfirmation.colQuantity": { en: "Quantity", fr: "Quantité", es: "Cantidad", de: "Menge" },
"storefront.orderConfirmation.colSubtotal": { en: "Subtotal", fr: "Sous-total", es: "Subtotal", de: "Zwischensumme" },
"storefront.orderConfirmation.total": { en: "Total: {{amount}}", fr: "Total : {{amount}}", es: "Total: {{amount}}", de: "Gesamt: {{amount}}" },
"storefront.orderConfirmation.backLink": { en: "Back to catalog", fr: "Retour au catalogue", es: "Volver al catálogo", de: "Zurück zum Katalog" },
// --- storefront account ---
"storefront.account.signIn": { en: "Sign in", fr: "Se connecter", es: "Iniciar sesión", de: "Anmelden" },
"storefront.account.createAccount": { en: "Create an account", fr: "Créer un compte", es: "Crear una cuenta", de: "Konto erstellen" },
"storefront.account.username": { en: "Username", fr: "Identifiant", es: "Usuario", de: "Benutzername" },
"storefront.account.password": { en: "Password", fr: "Mot de passe", es: "Contraseña", de: "Passwort" },
"storefront.account.submitSignIn": { en: "Sign in", fr: "Se connecter", es: "Iniciar sesión", de: "Anmelden" },
"storefront.account.submitCreate": { en: "Create account", fr: "Créer le compte", es: "Crear cuenta", de: "Konto erstellen" },
"storefront.account.pleaseWait": { en: "Please wait…", fr: "Veuillez patienter…", es: "Espera un momento…", de: "Bitte warten…" },
"storefront.account.noAccountYet": { en: "No account yet?", fr: "Pas encore de compte ?", es: "¿Aún no tienes cuenta?", de: "Noch kein Konto?" },
"storefront.account.register": { en: "Register", fr: "S'inscrire", es: "Registrarse", de: "Registrieren" },
"storefront.account.alreadyHaveAccount": { en: "Already have an account?", fr: "Vous avez déjà un compte ?", es: "¿Ya tienes una cuenta?", de: "Bereits ein Konto?" },
"storefront.account.unavailable": { en: "Customer accounts are not available for this shop right now.", fr: "Les comptes clients ne sont pas disponibles pour cette boutique actuellement.", es: "Las cuentas de clientes no están disponibles en esta tienda por ahora.", de: "Kundenkonten sind für diesen Shop derzeit nicht verfügbar." },
"storefront.account.title": { en: "Account", fr: "Compte", es: "Cuenta", de: "Konto" },
"storefront.account.myAccountTitle": { en: "My account", fr: "Mon compte", es: "Mi cuenta", de: "Mein Konto" },
"storefront.account.logout": { en: "Log out", fr: "Déconnexion", es: "Cerrar sesión", de: "Abmelden" },
"storefront.account.signedInAs": { en: "Signed in as {{username}}.", fr: "Connecté en tant que {{username}}.", es: "Sesión iniciada como {{username}}.", de: "Angemeldet als {{username}}." },
"storefront.account.errorLoginDisabled": { en: "Customer login is currently disabled for this shop.", fr: "La connexion client est actuellement désactivée pour cette boutique.", es: "El inicio de sesión de clientes está actualmente desactivado en esta tienda.", de: "Der Kundenlogin ist für diesen Shop derzeit deaktiviert." },
"storefront.account.errorRegistrationDisabled": { en: "Registration is currently disabled for this shop.", fr: "L'inscription est actuellement désactivée pour cette boutique.", es: "El registro está actualmente desactivado en esta tienda.", de: "Die Registrierung ist für diesen Shop derzeit deaktiviert." },
"storefront.account.errorUsernameTaken": { en: "That username is already taken.", fr: "Cet identifiant est déjà utilisé.", es: "Ese nombre de usuario ya está en uso.", de: "Dieser Benutzername ist bereits vergeben." },
"storefront.account.errorInvalidCredentials": { en: "Invalid username or password.", fr: "Identifiant ou mot de passe incorrect.", es: "Usuario o contraseña incorrectos.", de: "Ungültiger Benutzername oder Passwort." },
"storefront.account.errorGeneric": { en: "Something went wrong. Please try again.", fr: "Une erreur est survenue. Merci de réessayer.", es: "Algo salió mal. Inténtalo de nuevo.", de: "Etwas ist schiefgelaufen. Bitte versuche es erneut." },
// --- storefront verification panel ---
"storefront.verification.title": { en: "Identity verification", fr: "Vérification d'identité", es: "Verificación de identidad", de: "Identitätsprüfung" },
"storefront.verification.waitingTitle": { en: "Account pending verification", fr: "Compte en attente de vérification", es: "Cuenta pendiente de verificación", de: "Konto wartet auf Verifizierung" },
"storefront.verification.intro": { en: "This shop requires an approved identity verification. Submit your documents below — you'll get full access to the shop once an admin approves them.", fr: "Cette boutique nécessite une vérification d'identité approuvée. Envoyez vos documents ci-dessous — vous aurez accès à la boutique dès qu'un administrateur les aura approuvés.", es: "Esta tienda requiere una verificación de identidad aprobada. Envía tus documentos a continuación — tendrás acceso completo a la tienda en cuanto un administrador los apruebe.", de: "Dieser Shop erfordert eine genehmigte Identitätsprüfung. Reiche unten deine Dokumente ein — du erhältst vollen Zugriff auf den Shop, sobald eine Administration sie genehmigt hat." },
"storefront.verification.statusPending": { en: "Pending review", fr: "En cours d'examen", es: "En revisión", de: "Wird geprüft" },
"storefront.verification.statusApproved": { en: "Approved — you can check out", fr: "Approuvée — vous pouvez commander", es: "Aprobada — ya puedes pagar", de: "Genehmigt — du kannst zur Kasse gehen" },
"storefront.verification.statusRejected": { en: "Rejected — please resubmit", fr: "Rejetée — merci de soumettre à nouveau", es: "Rechazada — vuelve a enviarla", de: "Abgelehnt — bitte erneut einreichen" },
"storefront.verification.statusLabel": { en: "Status:", fr: "Statut :", es: "Estado:", de: "Status:" },
"storefront.verification.docFront": { en: "ID document — front", fr: "Pièce d'identité — recto", es: "Documento de identidad — anverso", de: "Ausweisdokument — Vorderseite" },
"storefront.verification.docBack": { en: "ID document — back", fr: "Pièce d'identité — verso", es: "Documento de identidad — reverso", de: "Ausweisdokument — Rückseite" },
"storefront.verification.submit": { en: "Submit for review", fr: "Soumettre pour examen", es: "Enviar para revisión", de: "Zur Prüfung einreichen" },
"storefront.verification.resubmit": { en: "Resubmit", fr: "Soumettre à nouveau", es: "Volver a enviar", de: "Erneut einreichen" },
"storefront.verification.submitting": { en: "Submitting…", fr: "Envoi…", es: "Enviando…", de: "Wird eingereicht…" },
"storefront.verification.submitError": { en: "Failed to submit documents. Make sure both are JPEG or PNG images.", fr: "Échec de l'envoi des documents. Vérifiez que les deux sont des images JPEG ou PNG.", es: "No se pudieron enviar los documentos. Asegúrate de que ambos sean imágenes JPEG o PNG.", de: "Dokumente konnten nicht eingereicht werden. Beide müssen JPEG- oder PNG-Bilder sein." },
// --- storefront order history ---
"storefront.orderHistory.title": { en: "Order history", fr: "Historique des commandes", es: "Historial de pedidos", de: "Bestellverlauf" },
"storefront.orderHistory.empty": { en: "No orders yet.", fr: "Aucune commande pour l'instant.", es: "Aún no hay pedidos.", de: "Noch keine Bestellungen." },
"storefront.orderHistory.colOrder": { en: "Order", fr: "Commande", es: "Pedido", de: "Bestellung" },
"storefront.orderHistory.colTotal": { en: "Total", fr: "Total", es: "Total", de: "Gesamt" },
} satisfies Record<string, MessageEntry>;
export type MessageKey = keyof typeof messages;
export function translate(locale: Locale, key: MessageKey, vars?: Record<string, string | number>): string {
const entry = messages[key];
let text: string = entry ? entry[locale] || entry.en : key;
if (vars) {
for (const [k, v] of Object.entries(vars)) {
text = text.replaceAll(`{{${k}}}`, String(v));
}
}
return text;
}
+1268 -29
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,12 +1,14 @@
import type { ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { useAuth } from "../features/auth/AuthContext";
import { useI18n } from "../i18n/LanguageContext";
export default function ProtectedRoute({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
const { t } = useI18n();
if (loading) {
return <div className="page-loading">Loading</div>;
return <div className="page-loading">{t("common.loading")}</div>;
}
if (!user) {
return <Navigate to="/login" replace />;
+39
View File
@@ -0,0 +1,39 @@
// Built-in brand icons for the "Contact links" admin feature: the admin
// picks one of these (or falls back to a custom uploaded image, or no icon
// at all) instead of having to source/upload a logo for every common app.
// Path data is the exact "simple-icons" (MIT) glyph for each brand; only
// the outer <svg> wrapper and default color are ours.
export interface SocialIconDef {
key: string;
label: string;
defaultColor: string;
path: string;
}
export const SOCIAL_ICONS: SocialIconDef[] = [
{ key: "whatsapp", label: "WhatsApp", defaultColor: "#25D366", path: "M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" },
{ key: "telegram", label: "Telegram", defaultColor: "#26A5E4", path: "M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" },
{ key: "instagram", label: "Instagram", defaultColor: "#E4405F", path: "M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077" },
{ key: "signal", label: "Signal", defaultColor: "#3A76F0", path: "M12 0q-.934 0-1.83.139l.17 1.111a11 11 0 0 1 3.32 0l.172-1.111A12 12 0 0 0 12 0M9.152.34A12 12 0 0 0 5.77 1.742l.584.961a10.8 10.8 0 0 1 3.066-1.27zm5.696 0-.268 1.094a10.8 10.8 0 0 1 3.066 1.27l.584-.962A12 12 0 0 0 14.848.34M12 2.25a9.75 9.75 0 0 0-8.539 14.459c.074.134.1.292.064.441l-1.013 4.338 4.338-1.013a.62.62 0 0 1 .441.064A9.7 9.7 0 0 0 12 21.75c5.385 0 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25m-7.092.068a12 12 0 0 0-2.59 2.59l.909.664a11 11 0 0 1 2.345-2.345zm14.184 0-.664.909a11 11 0 0 1 2.345 2.345l.909-.664a12 12 0 0 0-2.59-2.59M1.742 5.77A12 12 0 0 0 .34 9.152l1.094.268a10.8 10.8 0 0 1 1.269-3.066zm20.516 0-.961.584a10.8 10.8 0 0 1 1.27 3.066l1.093-.268a12 12 0 0 0-1.402-3.383M.138 10.168A12 12 0 0 0 0 12q0 .934.139 1.83l1.111-.17A11 11 0 0 1 1.125 12q0-.848.125-1.66zm23.723.002-1.111.17q.125.812.125 1.66c0 .848-.042 1.12-.125 1.66l1.111.172a12.1 12.1 0 0 0 0-3.662M1.434 14.58l-1.094.268a12 12 0 0 0 .96 2.591l-.265 1.14 1.096.255.36-1.539-.188-.365a10.8 10.8 0 0 1-.87-2.35m21.133 0a10.8 10.8 0 0 1-1.27 3.067l.962.584a12 12 0 0 0 1.402-3.383zm-1.793 3.848a11 11 0 0 1-2.345 2.345l.664.909a12 12 0 0 0 2.59-2.59zm-19.959 1.1L.357 21.48a1.8 1.8 0 0 0 2.162 2.161l1.954-.455-.256-1.095-1.953.455a.675.675 0 0 1-.81-.81l.454-1.954zm16.832 1.769a10.8 10.8 0 0 1-3.066 1.27l.268 1.093a12 12 0 0 0 3.382-1.402zm-10.94.213-1.54.36.256 1.095 1.139-.266c.814.415 1.683.74 2.591.961l.268-1.094a10.8 10.8 0 0 1-2.35-.869zm3.634 1.24-.172 1.111a12.1 12.1 0 0 0 3.662 0l-.17-1.111q-.812.125-1.66.125a11 11 0 0 1-1.66-.125" },
{ key: "snapchat", label: "Snapchat", defaultColor: "#FFFC00", path: "M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z" },
// Not a real brand -- "Potato" (Potato Chat)'s actual logo is a rocket,
// and it has no entry in simple-icons, so this is a plain hand-drawn
// rocket silhouette (body + two fins + flame) rather than an invented
// fake logo.
{ key: "potato", label: "Potato", defaultColor: "#FF7A45", path: "M12 2c-2.2 2.2-3.5 5.3-3.5 9v5h7v-5c0-3.7-1.3-6.8-3.5-9zM8.5 13.7l-3.5 3.5v3l3.5-2zM15.5 13.7l3.5 3.5v3l-3.5-2zM10.3 16.3h3.4l-1.7 5z" },
{ key: "simplex", label: "SimpleX", defaultColor: "#000000", path: "m16.1 0-4.026 4.025L8.125.076 6.113 2.09l3.95 3.947-3.975 3.977L2.14 6.066.109 8.096l3.948 3.947L0 16.1l1.975 1.972 4.056-4.056 3.95 3.947 2.029-2.027-3.95-3.95 3.975-3.972 3.951 3.949-4.025 4.023v.002L9.947 18l-4.023 4.025L7.896 24l4.026-4.025 3.95 3.949 2.013-2.014-3.951-3.95 4.027-4.024 3.95 3.949 2.013-2.012-3.95-3.95L24 7.899l-1.975-1.972L18 9.949 14.049 6l4.025-4.025z" },
];
const SOCIAL_ICONS_BY_KEY = new Map(SOCIAL_ICONS.map((icon) => [icon.key, icon]));
export function getSocialIcon(key: string | undefined | null): SocialIconDef | undefined {
return key ? SOCIAL_ICONS_BY_KEY.get(key) : undefined;
}
export function SocialIconGlyph({ path, className }: { path: string; className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path d={path} />
</svg>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
export type Theme = "dark" | "light";
const STORAGE_KEY = "ui_theme";
function detectInitialTheme(): Theme {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "dark" || stored === "light") return stored;
return window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(detectInitialTheme);
useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem(STORAGE_KEY, theme);
}, [theme]);
function toggleTheme() {
setTheme((current) => (current === "dark" ? "light" : "dark"));
}
return <ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within a ThemeProvider");
return ctx;
}
+20
View File
@@ -0,0 +1,20 @@
import { useTheme } from "./ThemeContext";
import { MoonIcon, SunIcon } from "../ui/icons";
// Reused as-is in both the admin topbar and the storefront header, right
// next to LanguageSwitcher -- same boxed icon-button treatment.
export default function ThemeToggle({ className }: { className?: string }) {
const { theme, toggleTheme } = useTheme();
return (
<button
type="button"
className={`theme-toggle ${className ?? ""}`}
onClick={toggleTheme}
aria-label="Toggle theme"
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
</button>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from "react";
// Plain <details>/<summary> disclosure -- free keyboard/accessibility
// support and no open/close state to manage. Used to break the Appearance
// admin page's many settings groups (Header, Body, Hero, Layout, ...) into
// collapsible sections that expand downward instead of one long form.
export default function AccordionSection({
title,
defaultOpen = false,
children,
}: {
title: string;
defaultOpen?: boolean;
children: ReactNode;
}) {
return (
<details className="accordion-section" open={defaultOpen}>
<summary className="accordion-summary">{title}</summary>
<div className="accordion-content">{children}</div>
</details>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
import Modal from "./Modal";
import { useI18n } from "../i18n/LanguageContext";
export interface ConfirmOptions {
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
/** Styles the confirm button as destructive (red) -- used for deletes. */
danger?: boolean;
}
type ConfirmFn = (options: ConfirmOptions | string) => Promise<boolean>;
const ConfirmContext = createContext<ConfirmFn | undefined>(undefined);
// Custom replacement for window.confirm(): every "delete this X?" prompt in
// the admin panel awaits confirm(message) instead, rendering as an
// app-style bottom sheet instead of the browser's native dialog.
export function ConfirmProvider({ children }: { children: ReactNode }) {
const { t } = useI18n();
const [options, setOptions] = useState<ConfirmOptions | null>(null);
const resolverRef = useRef<((value: boolean) => void) | null>(null);
const confirm = useCallback<ConfirmFn>((input) => {
const normalized = typeof input === "string" ? { message: input } : input;
setOptions(normalized);
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve;
});
}, []);
function settle(result: boolean) {
setOptions(null);
resolverRef.current?.(result);
resolverRef.current = null;
}
return (
<ConfirmContext.Provider value={confirm}>
{children}
<Modal open={options !== null} onClose={() => settle(false)} labelledBy="confirm-modal-title">
{options && (
<div className="confirm-modal">
<h3 id="confirm-modal-title">{options.title ?? t("common.confirmTitle")}</h3>
<p>{options.message}</p>
<div className="modal-actions">
<button type="button" className="btn" onClick={() => settle(false)}>
{options.cancelLabel ?? t("common.cancel")}
</button>
<button
type="button"
className={`btn ${options.danger ? "btn-danger" : "btn-primary"}`}
onClick={() => settle(true)}
>
{options.confirmLabel ?? (options.danger ? t("common.delete") : t("common.confirm"))}
</button>
</div>
</div>
)}
</Modal>
</ConfirmContext.Provider>
);
}
export function useConfirm(): ConfirmFn {
const ctx = useContext(ConfirmContext);
if (!ctx) throw new Error("useConfirm must be used within a ConfirmProvider");
return ctx;
}
+54
View File
@@ -0,0 +1,54 @@
import { useEffect, type ReactNode } from "react";
// Renders as a bottom sheet on phones (slides up, grab-handle) and a
// centered dialog on wider screens -- see .modal-* rules in index.css.
export default function Modal({
open,
onClose,
children,
labelledBy,
}: {
open: boolean;
onClose: () => void;
children: ReactNode;
labelledBy?: string;
}) {
useEffect(() => {
if (!open) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
// Empêche le scroll de la page derrière la modal.
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
document.addEventListener("keydown", onKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener("keydown", onKeyDown);
};
}, [open, onClose]);
if (!open) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div
className="modal-sheet"
role="dialog"
aria-modal="true"
aria-labelledby={labelledBy}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-grabber" />
<div className="modal-scroll-content">
{children}
</div>
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react";
interface ToastItem {
id: number;
type: "success" | "error";
text: string;
}
interface ToastApi {
success: (text: string) => void;
error: (text: string) => void;
}
const ToastContext = createContext<ToastApi | undefined>(undefined);
const AUTO_DISMISS_MS = 3500;
// Custom replacement for the old inline <div className="alert"> pattern:
// success/error feedback after create/update/delete now surfaces as a
// floating app-style toast instead of a banner embedded in the page.
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
const nextId = useRef(0);
const dismiss = useCallback((id: number) => {
setItems((current) => current.filter((item) => item.id !== id));
}, []);
const push = useCallback(
(type: ToastItem["type"], text: string) => {
const id = nextId.current++;
setItems((current) => [...current, { id, type, text }]);
setTimeout(() => dismiss(id), AUTO_DISMISS_MS);
},
[dismiss],
);
const api: ToastApi = {
success: (text) => push("success", text),
error: (text) => push("error", text),
};
return (
<ToastContext.Provider value={api}>
{children}
<div className="toast-stack" aria-live="polite">
{items.map((item) => (
<div key={item.id} className={`toast toast-${item.type}`} onClick={() => dismiss(item.id)}>
<span className="toast-icon">{item.type === "success" ? "✓" : "!"}</span>
<span>{item.text}</span>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastApi {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within a ToastProvider");
return ctx;
}
+168
View File
@@ -0,0 +1,168 @@
// Minimal hand-built line icons (no icon-library dependency) shared by the
// admin and storefront bottom navigation bars. Same rendering convention as
// socialIcons.tsx: plain SVG, sized by the parent's font-size via em-based
// CSS, colored via currentColor.
import type { SVGProps } from "react";
function IconBase(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 24 24"
width="1em"
height="1em"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
/>
);
}
export function HomeIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M4 11.5 12 4l8 7.5" />
<path d="M6 10.5V20h12v-9.5" />
<path d="M10 20v-5h4v5" />
</IconBase>
);
}
export function CartIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M6 8h12l-1 12H7L6 8Z" />
<path d="M9 8V6a3 3 0 0 1 6 0v2" />
</IconBase>
);
}
export function UserIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<circle cx="12" cy="8" r="3.5" />
<path d="M5 20c0-4 3-6 7-6s7 2 7 6" />
</IconBase>
);
}
export function GridIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<rect x="4" y="4" width="7" height="7" rx="1.5" />
<rect x="13" y="4" width="7" height="7" rx="1.5" />
<rect x="4" y="13" width="7" height="7" rx="1.5" />
<rect x="13" y="13" width="7" height="7" rx="1.5" />
</IconBase>
);
}
export function ListIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M9 6h11" />
<path d="M9 12h11" />
<path d="M9 18h11" />
<circle cx="4.5" cy="6" r="1.1" fill="currentColor" stroke="none" />
<circle cx="4.5" cy="12" r="1.1" fill="currentColor" stroke="none" />
<circle cx="4.5" cy="18" r="1.1" fill="currentColor" stroke="none" />
</IconBase>
);
}
export function MoreIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<circle cx="6" cy="12" r="1.3" fill="currentColor" stroke="none" />
<circle cx="12" cy="12" r="1.3" fill="currentColor" stroke="none" />
<circle cx="18" cy="12" r="1.3" fill="currentColor" stroke="none" />
</IconBase>
);
}
export function InfoIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<circle cx="12" cy="12" r="8.5" />
<circle cx="12" cy="8.3" r="0.9" fill="currentColor" stroke="none" />
<path d="M12 11.5v5.5" />
</IconBase>
);
}
export function ContactIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<circle cx="10" cy="8" r="3" />
<path d="M4.5 19c0-3.2 2.2-5 5.5-5s5.5 1.8 5.5 5" />
<path d="M16 13.5h3.5v5H16l-2 1.5v-1.5" />
</IconBase>
);
}
export function LogOutIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M10 5H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h4" />
<path d="M14 8l4 4-4 4" />
<path d="M18 12H9" />
</IconBase>
);
}
export function ImageIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<rect x="3.5" y="4.5" width="17" height="15" rx="2" />
<circle cx="8.5" cy="9.5" r="1.5" />
<path d="M20.5 15.5 15 10l-9 9" />
</IconBase>
);
}
export function ChevronLeftIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M15 5.5 8 12l7 6.5" />
</IconBase>
);
}
export function ChevronRightIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M9 5.5 16 12l-7 6.5" />
</IconBase>
);
}
export function SunIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<circle cx="12" cy="12" r="4.2" />
<path d="M12 2.5v2.4M12 19.1v2.4M4.4 4.4l1.7 1.7M17.9 17.9l1.7 1.7M2.5 12h2.4M19.1 12h2.4M4.4 19.6l1.7-1.7M17.9 6.1l1.7-1.7" />
</IconBase>
);
}
export function MoonIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z" />
</IconBase>
);
}
export function LanguageIcon(props: SVGProps<SVGSVGElement>) {
return (
<IconBase {...props}>
<circle cx="12" cy="12" r="8.5" />
<path d="M3.5 12h17" />
<path d="M12 3.5c2.2 2.3 3.3 5.1 3.3 8.5s-1.1 6.2-3.3 8.5" />
<path d="M12 3.5c-2.2 2.3-3.3 5.1-3.3 8.5s1.1 6.2 3.3 8.5" />
</IconBase>
);
}