chore: build
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.log
|
||||
.git/
|
||||
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Generated
-6
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">← Back to orders</Link>
|
||||
<Link to="/admin/orders">← {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} — {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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">← Back to products</Link>
|
||||
<Link to="/admin/products">← {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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, ...) — 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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="/">← {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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 />;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user