feat(customers): customer portal (#354) on top of feature-flags reorg

Implements the recurring-customer login surface from
the-luap/picpeak#354 plugged into the maintainer's
new feature-flag infrastructure (PR #443) instead of
a parallel toggle.

* New `customerPortal` feature flag (foundation flag for the
  not-yet-built calendar/quotes/bills/messaging customer
  surfaces). Defaults FALSE on fresh installs, TRUE on existing
  installs (events > 0) via migration 095 so live customer
  accounts don't disappear mid-deployment.
* Foundation schema: customer_accounts, customer_invitations,
  event_customer_assignments, customer_password_resets, plus
  RBAC permissions customers.view / .create / .delete granted
  to super_admin + admin system roles.
* Backend: /api/admin/customers (invite, list, search, assign,
  deactivate, reset password) + /api/customer/auth/* +
  /api/customer/* (login, dashboard, accept-invite, reset).
  Customer JWT bypass minted via
  /api/customer/events/:slug/access-token so existing gallery
  middleware stays untouched.
* Frontend: /customer/* route tree gated by RequireFeature flag
  customerPortal, with login / dashboard / accept-invite /
  reset pages and a customer-side sidebar layout.
  /admin/customers and /admin/customers/:id gated identically.
* Settings → Features grows a "Customers" section with a
  Customer portal card. The maintainer's Features tab stays the
  single source of truth — no parallel Advanced features tab.
* CustomerAccountPicker on event create/edit forms hides itself
  when the flag is off; backend ignores customer_account_ids in
  that case instead of erroring the whole event save.

Translations: en + de hand-translated. nl/pt/ru fall through to
en — flagged here as needing native review.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Luca
2026-05-11 00:05:20 +02:00
co-authored by Claude Opus 4.6
parent f2f48f31b0
commit 087ef45942
54 changed files with 9816 additions and 54 deletions
+53
View File
@@ -22,9 +22,23 @@ import {
AnalyticsPage,
SettingsPage,
UserManagementPage,
CustomerManagementPage,
CustomerDetailPage,
WebhookDeliveriesPage
} from './pages/admin';
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
import {
CustomerLoginPage,
CustomerDashboardPage,
CustomerAcceptInvitePage,
CustomerLayout,
CustomerProfilePage,
CustomerCalendarPage,
CustomerQuotesPage,
CustomerBillsPage,
CustomerResetPasswordPage,
} from './pages/customer';
import { CustomerAuthProvider } from './contexts/CustomerAuthContext';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { RequireFeature } from './components/admin/RequireFeature';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common';
@@ -132,6 +146,13 @@ function App() {
<Route element={<RequireFeature flag="userManagement" />}>
<Route path="users" element={<UserManagementPage />} />
</Route>
{/* Customer accounts (#354) — admin-side management.
Hidden from sidebar + redirected away when the
customerPortal flag is off. */}
<Route element={<RequireFeature flag="customerPortal" />}>
<Route path="customers" element={<CustomerManagementPage />} />
<Route path="customers/:id" element={<CustomerDetailPage />} />
</Route>
<Route path="settings" element={<SettingsPage />} />
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
@@ -153,6 +174,38 @@ function App() {
{/* Public invitation acceptance page */}
<Route path="/invite/:token" element={<AcceptInvitePage />} />
{/* Customer surface (#354). Strictly separate provider /
cookie / API surface from /admin/*. Gated by the
customerPortal feature flag — when off, all
/customer/* URLs redirect to /admin/dashboard. */}
<Route element={<RequireFeature flag="customerPortal" fallback="/admin/login" />}>
<Route path="/customer/*" element={
<CustomerAuthProvider>
<Routes>
{/* Public surfaces: login, accept-invite, reset —
no CustomerLayout (their own branded shells). */}
<Route path="login" element={<CustomerLoginPage />} />
<Route path="invite/:token" element={<CustomerAcceptInvitePage />} />
<Route path="reset-password/:token" element={<CustomerResetPasswordPage />} />
{/* Authenticated surfaces share the sidebar layout
(Outlet pattern, mirrors AdminLayout). The
CustomerLayout itself enforces auth — bouncing
unauthenticated visitors to /customer/login. */}
<Route element={<CustomerLayout />}>
<Route path="dashboard" element={<CustomerDashboardPage />} />
<Route path="calendar" element={<CustomerCalendarPage />} />
<Route path="quotes" element={<CustomerQuotesPage />} />
<Route path="bills" element={<CustomerBillsPage />} />
<Route path="profile" element={<CustomerProfilePage />} />
</Route>
<Route index element={<Navigate to="/customer/dashboard" replace />} />
</Routes>
</CustomerAuthProvider>
} />
</Route>
{/* Public legal pages */}
<Route path="/impressum" element={<LegalPage />} />
<Route path="/datenschutz" element={<LegalPage />} />
@@ -8,6 +8,7 @@ import {
Settings,
X,
Users,
UserCog,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -46,6 +47,12 @@ const navigation: NavItem[] = [
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view', featureFlag: 'userManagement' },
// Customer accounts (#354) — separate from admin users (#users.view)
// by design: customers log in at /customer/login with their own
// cookie + token type. Hidden when the customerPortal feature flag
// is OFF (Settings → Features). The corresponding /customer/* routes
// also redirect away in that case (see RequireFeature in App.tsx).
{ nameKey: 'navigation.customers', href: '/admin/customers', icon: UserCog, permission: 'customers.view', featureFlag: 'customerPortal' },
];
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
@@ -0,0 +1,202 @@
/**
* CustomerAccountPicker (#354).
*
* Multi-select autocomplete used on the event create / edit forms to
* assign customer accounts to an event. Anyone selected here gets
* dashboard access + can bypass the per-event password.
*
* Backed by GET /api/admin/customers/search (debounced 200ms).
* Selected values render as removable chips so the form can stay compact.
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Search, X, UserPlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { customerAdminService, type CustomerAccountSummary } from '../../services/customerAdmin.service';
import { useFeatureEnabled } from '../../contexts/FeatureFlagsContext';
export interface SelectedCustomer {
id: number;
email: string;
displayName: string | null;
}
interface Props {
value: SelectedCustomer[];
onChange: (next: SelectedCustomer[]) => void;
disabled?: boolean;
}
const labelFor = (c: { email: string; displayName?: string | null; companyName?: string | null }) => {
const display = c.displayName?.trim() || c.companyName?.trim();
return display ? `${display} · ${c.email}` : c.email;
};
export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabled }) => {
const { t } = useTranslation();
const customerPortalEnabled = useFeatureEnabled('customerPortal');
// Gate the entire picker on the customerPortal feature flag. When off,
// the backend returns 410 on /admin/customers/search anyway, but hiding
// the UI here keeps the event form clean and removes the dangling
// "Customer accounts" label that would otherwise appear above an
// empty/error placeholder.
if (!customerPortalEnabled) return null;
const [query, setQuery] = useState('');
const [results, setResults] = useState<CustomerAccountSummary[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Debounced search. Aborts in-flight requests so a fast typer doesn't
// see an old result win the race over a newer one.
useEffect(() => {
const term = query.trim();
if (!term) {
setResults([]);
setIsSearching(false);
return;
}
setIsSearching(true);
let cancelled = false;
const handle = window.setTimeout(async () => {
try {
const rows = await customerAdminService.search(term);
if (!cancelled) {
// Filter out already-selected ids on the client. Cheaper than
// round-tripping the selection state to the server.
const selectedIds = new Set(value.map((v) => v.id));
setResults(rows.filter((r) => !selectedIds.has(r.id)));
}
} catch {
if (!cancelled) setResults([]);
} finally {
if (!cancelled) setIsSearching(false);
}
}, 200);
return () => { cancelled = true; window.clearTimeout(handle); };
}, [query, value]);
// Click-outside to close. Listening on mousedown matches what the
// existing AdminHeader notification dropdown uses.
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, []);
const select = (c: CustomerAccountSummary) => {
onChange([...value, { id: c.id, email: c.email, displayName: c.displayName }]);
setQuery('');
setResults([]);
setIsOpen(false);
};
const remove = (id: number) => {
onChange(value.filter((v) => v.id !== id));
};
const helpText = useMemo(
() => t(
'events.customerPicker.help',
'Customers added here can log in at /customer/login and view this gallery without entering the per-event password.'
),
[t]
);
return (
<div ref={containerRef} className="relative">
<label className="block text-sm font-medium text-theme mb-1">
{t('events.customerPicker.label', 'Customer accounts')}
</label>
<p className="text-xs text-muted-theme mb-2">{helpText}</p>
{/* Selected chips */}
{value.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{value.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs"
style={{
backgroundColor: 'var(--color-elevated, #f5f5f5)',
color: 'var(--color-text)',
border: '1px solid var(--color-surface-border, #e5e5e5)',
}}
>
<span className="font-medium">{c.displayName?.trim() || c.email}</span>
{c.displayName?.trim() && c.email !== c.displayName && (
<span className="text-muted-theme">· {c.email}</span>
)}
{!disabled && (
<button
type="button"
onClick={() => remove(c.id)}
className="ml-1 -mr-1 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 p-0.5"
aria-label={t('events.customerPicker.removeAria', 'Remove {{name}}', { name: c.email })}
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
</div>
)}
{/* Search input */}
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 pointer-events-none" />
<input
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)}
disabled={disabled}
placeholder={t('events.customerPicker.placeholder', 'Search by email, name, or company')}
className="input pl-9"
/>
</div>
{/* Dropdown */}
{isOpen && query.trim() !== '' && (
<div
className="absolute left-0 right-0 mt-1 z-20 rounded-lg shadow-lg border max-h-72 overflow-y-auto"
style={{
backgroundColor: 'var(--color-surface, #ffffff)',
borderColor: 'var(--color-surface-border, #e5e5e5)',
}}
>
{isSearching ? (
<div className="px-3 py-3 text-sm text-muted-theme">
{t('events.customerPicker.searching', 'Searching…')}
</div>
) : results.length === 0 ? (
<div className="px-3 py-3 text-sm text-muted-theme">
{t('events.customerPicker.noResults', 'No matches. Invite this customer from /admin/customers first.')}
</div>
) : (
<ul role="listbox">
{results.map((r) => (
<li key={r.id}>
<button
type="button"
onClick={() => select(r)}
className="w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
>
<UserPlus className="w-4 h-4 text-muted-theme flex-shrink-0" />
<span className="flex-1 truncate">{labelFor(r)}</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
};
export default CustomerAccountPicker;
@@ -0,0 +1,203 @@
/**
* Customer-side React auth context (#354).
*
* Sibling of AdminAuthContext / GalleryAuthContext but operates on a
* separate cookie (customer_token) and a separate API surface
* (/api/customer/auth/*). The contexts are isolated by design so that
* a single browser can hold an admin session AND a customer session
* without one clobbering the other (e.g. for the admin dogfooding the
* customer dashboard).
*
* #354 follow-up: also surfaces the effective feature set and the
* branding visibility flags so CustomerLayout can render the sidebar
* without an extra round trip on every navigation.
*/
import React, { createContext, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { customerService, type CustomerProfile } from '../services/customer.service';
export interface CustomerFeatureFlags {
calendar: boolean;
quotes: boolean;
bills: boolean;
}
export interface CustomerBrandingFlags {
showLogo: boolean;
showCompanyName: boolean;
}
interface CustomerAuthContextType {
isAuthenticated: boolean;
customer: CustomerProfile | null;
features: CustomerFeatureFlags;
branding: CustomerBrandingFlags;
isLoading: boolean;
error: string | null;
/** Replaces the cached profile after a successful POST /login. */
setCustomer: (c: CustomerProfile) => void;
/**
* Replaces customer + features + branding atomically. Used by the
* login page so the dashboard's first paint after login shows the
* correct sidebar (without this, features default to `false` and the
* Soon menus would only appear after the next CustomerAuthProvider
* re-mount, e.g. after navigating to a gallery and back).
*/
setSession: (s: { customer: CustomerProfile; features: CustomerFeatureFlags; branding: CustomerBrandingFlags }) => void;
logout: () => Promise<void>;
}
const CustomerAuthContext = createContext<CustomerAuthContextType | undefined>(undefined);
export const useCustomerAuth = () => {
const ctx = useContext(CustomerAuthContext);
if (!ctx) {
throw new Error('useCustomerAuth must be used within a CustomerAuthProvider');
}
return ctx;
};
const STORAGE_KEY = 'customer_profile';
const FEATURES_KEY = 'customer_features';
const BRANDING_KEY = 'customer_branding';
const DEFAULT_FEATURES: CustomerFeatureFlags = { calendar: false, quotes: false, bills: false };
const DEFAULT_BRANDING: CustomerBrandingFlags = { showLogo: true, showCompanyName: true };
interface ProviderProps { children: ReactNode; }
export const CustomerAuthProvider: React.FC<ProviderProps> = ({ children }) => {
const [customer, setCustomerState] = useState<CustomerProfile | null>(null);
const [features, setFeatures] = useState<CustomerFeatureFlags>(DEFAULT_FEATURES);
const [branding, setBranding] = useState<CustomerBrandingFlags>(DEFAULT_BRANDING);
const [isLoading, setIsLoading] = useState(true);
// Reserved for future surface-level errors (login form errors are
// handled inline on the login page itself, not here).
const [error] = useState<string | null>(null);
/**
* Refetch the session from /api/customer/auth/session and update both
* React state and sessionStorage caches. Called on initial mount AND
* on window focus, so an admin who toggles a per-customer feature in
* one tab sees the change reflected in the customer tab the moment
* they switch back. Without this, the layout reads only from the
* mount-time sessionStorage cache and stays stale until a hard reload.
*/
const refreshSession = React.useCallback(async () => {
try {
const response = await customerService.session();
if (response?.customer) {
setCustomerState(response.customer);
setFeatures(response.features);
setBranding(response.branding);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(response.customer));
sessionStorage.setItem(FEATURES_KEY, JSON.stringify(response.features));
sessionStorage.setItem(BRANDING_KEY, JSON.stringify(response.branding));
} else {
setCustomerState(null);
sessionStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(FEATURES_KEY);
sessionStorage.removeItem(BRANDING_KEY);
}
} catch {
setCustomerState(null);
sessionStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(FEATURES_KEY);
sessionStorage.removeItem(BRANDING_KEY);
}
}, []);
useEffect(() => {
// Hydrate immediately from sessionStorage so the dashboard avoids
// a flicker on hard refresh; the network call below confirms the
// cookie is still valid and overwrites stale data.
try {
const cached = sessionStorage.getItem(STORAGE_KEY);
if (cached) setCustomerState(JSON.parse(cached));
const cachedFeatures = sessionStorage.getItem(FEATURES_KEY);
if (cachedFeatures) setFeatures(JSON.parse(cachedFeatures));
const cachedBranding = sessionStorage.getItem(BRANDING_KEY);
if (cachedBranding) setBranding(JSON.parse(cachedBranding));
} catch {
sessionStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(FEATURES_KEY);
sessionStorage.removeItem(BRANDING_KEY);
}
let cancelled = false;
refreshSession().finally(() => {
if (!cancelled) setIsLoading(false);
});
// Refetch on tab/window focus so admin-side changes (per-customer
// feature toggles, branding visibility, deactivation) reach the
// customer browser without requiring a manual page reload.
const onFocus = () => { void refreshSession(); };
const onVisibility = () => {
if (document.visibilityState === 'visible') void refreshSession();
};
window.addEventListener('focus', onFocus);
document.addEventListener('visibilitychange', onVisibility);
// Periodic background refresh — covers the case where the customer
// tab stays foregrounded for a long stretch (no focus/visibility
// events fire) but admin has flipped a global toggle in another
// browser. 60 seconds matches the usePublicSettings react-query
// staleTime so branding + feature flags stay roughly in sync.
const interval = window.setInterval(() => {
if (document.visibilityState === 'visible') void refreshSession();
}, 60_000);
return () => {
cancelled = true;
window.removeEventListener('focus', onFocus);
document.removeEventListener('visibilitychange', onVisibility);
window.clearInterval(interval);
};
}, [refreshSession]);
const setCustomer = (c: CustomerProfile) => {
setCustomerState(c);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(c));
};
const setSession = (s: { customer: CustomerProfile; features: CustomerFeatureFlags; branding: CustomerBrandingFlags }) => {
setCustomerState(s.customer);
setFeatures(s.features);
setBranding(s.branding);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s.customer));
sessionStorage.setItem(FEATURES_KEY, JSON.stringify(s.features));
sessionStorage.setItem(BRANDING_KEY, JSON.stringify(s.branding));
};
const logout = async () => {
await customerService.logout();
setCustomerState(null);
setFeatures(DEFAULT_FEATURES);
setBranding(DEFAULT_BRANDING);
sessionStorage.removeItem(STORAGE_KEY);
sessionStorage.removeItem(FEATURES_KEY);
sessionStorage.removeItem(BRANDING_KEY);
// Hard navigate so any in-flight requests with the old cookie don't
// race the cleared session — same approach AdminAuthContext uses.
window.location.href = '/customer/login';
};
return (
<CustomerAuthContext.Provider
value={{
isAuthenticated: !!customer,
customer,
features,
branding,
isLoading,
error,
setCustomer,
setSession,
logout,
}}
>
{children}
</CustomerAuthContext.Provider>
);
};
@@ -19,6 +19,12 @@ export const DEFAULT_FLAGS: FeatureFlags = {
messaging: false,
analytics: true,
userManagement: true,
// Customer portal (#354) defaults OFF on a fresh install — picpeak
// ships as a focused gallery delivery tool, recurring-customer
// logins are opt-in. Migration 094 flips this to TRUE on existing
// installs (events>0) so the customer-portal foundation isn't
// silently disabled mid-deployment.
customerPortal: false,
};
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
@@ -50,9 +56,27 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
out.galleries = true; // foundation — always on
if (out.quotes === false) out.bills = false; // bills depend on quotes
if (out.calendar === false) out.calendarBooking = false; // booking depends on calendar
// Customer-portal-dependent flags: if the customer portal is OFF the
// customer-side dashboard never renders, so the calendar/quotes/bills/
// messaging customer-side surfaces have nowhere to live. Their server
// toggles can stay at whatever the admin set (so re-enabling the
// portal restores the previous state), but the dependency is
// documented here for the FeaturesTab UI to disable child cards
// visually when customerPortal is off.
return out;
}
/**
* Flags whose customer-side surface only renders when the customer
* portal is on. The FeaturesTab uses this to disable the toggle on
* child cards when customerPortal=false (with a "requires Customer
* portal" tooltip), so the admin doesn't flip something that has no
* visible effect.
*/
export const CUSTOMER_PORTAL_DEPENDENT_FLAGS: FeatureKey[] = [
'calendar', 'calendarBooking', 'quotes', 'bills', 'messaging',
];
function flagsEqual(a: FeatureFlags, b: FeatureFlags): boolean {
return (Object.keys(a) as FeatureKey[]).every((k) => a[k] === b[k]);
}
@@ -11,6 +11,7 @@ import {
Receipt,
BarChart3,
Users,
UserCog,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../../../components/common';
@@ -107,6 +108,26 @@ export const FeaturesTab: React.FC = () => {
/>
</Section>
{/* Customer accounts (#354) — recurring customer logins. The
calendar / quotes / bills / messaging cards below are
customer-side surfaces; they only render in the customer
dashboard when this is on. */}
<Section title={t('settings.features.sections.customers', 'Customers')}>
<FeatureCard
icon={UserCog}
title={t('settings.features.customerPortal.title', 'Customer portal')}
description={t(
'settings.features.customerPortal.description',
'Persistent customer logins. Recurring clients see all their assigned galleries from one place — no per-event passwords. Foundation for Calendar / Quotes / Bills (which only render in the customer dashboard when this is on).',
)}
status="beta"
statusLabel={statusLabel('beta')}
sidebarLabel={t('navigation.customers', 'Customers')}
enabled={staged.customerPortal}
onToggle={(next) => setFlag('customerPortal', next)}
/>
</Section>
{/* Communication */}
<Section title={t('settings.features.sections.communication', 'Communication')}>
<FeatureCard
+293 -2
View File
@@ -167,7 +167,8 @@
"backup": "Backup & Wiederherstellung",
"cmsPages": "CMS-Seiten",
"users": "Benutzer",
"calendar": "Kalender"
"calendar": "Kalender",
"customers": "Kunden"
},
"eventTypes": {
"title": "Veranstaltungsarten",
@@ -1446,7 +1447,8 @@
"communication": "Kommunikation",
"scheduling": "Terminplanung",
"sales": "Vertrieb",
"insights": "Auswertungen & Zugriff"
"insights": "Auswertungen & Zugriff",
"customers": "Kunden"
},
"status": {
"stable": "stabil",
@@ -1492,6 +1494,10 @@
"title": "Benutzerverwaltung",
"description": "Multi-Admin-Unterstützung mit rollenbasierten Berechtigungen. Deaktivieren Sie dies, wenn Sie ein Einzelbetreiber sind.",
"warning": "Bestehende Benutzerkonten bleiben gültig; die Admin-Oberfläche für deren Verwaltung wird ausgeblendet, bis Sie dies wieder aktivieren."
},
"customerPortal": {
"title": "Kundenportal",
"description": "Persistente Kunden-Logins. Wiederkehrende Kunden sehen alle zugeordneten Galerien an einem Ort — keine Passwörter pro Event. Grundlage für Kalender / Angebote / Rechnungen (die nur im Kundendashboard erscheinen, wenn diese Option aktiviert ist)."
}
}
},
@@ -2540,5 +2546,290 @@
"movedToCategory_other": "{{count}} Fotos nach {{category}} verschoben",
"moveToCategoryFailed": "Fotos konnten nicht in die Kategorie verschoben werden",
"moveToCategory": "In Kategorie verschieben"
},
"customer": {
"login": {
"title": "Kunden-Login",
"subtitle": "Zugriff auf alle Ihre Fotogalerien an einem Ort.",
"email": "E-Mail",
"password": "Passwort",
"emailRequired": "E-Mail ist erforderlich",
"invalidEmail": "Bitte geben Sie eine gültige E-Mail ein",
"passwordRequired": "Passwort ist erforderlich",
"showPassword": "Passwort anzeigen",
"hidePassword": "Passwort verbergen",
"signIn": "Anmelden",
"loginSuccess": "Willkommen zurück!",
"invalidCredentials": "E-Mail oder Passwort ist falsch",
"tooManyAttempts": "Zu viele Versuche — bitte später erneut versuchen.",
"networkError": "Server nicht erreichbar. Bitte erneut versuchen.",
"generalError": "Anmeldung fehlgeschlagen. Bitte erneut versuchen.",
"acceptedToast": "Konto bereit — bitte anmelden.",
"adminHint": "Sie suchen das Admin-Panel? Besuchen Sie /admin/login.",
"emailPlaceholder": "[email protected]",
"passwordPlaceholder": "Dein Passwort",
"needHelp": "Brauchst du Hilfe?",
"poweredBy": "Bereitgestellt von PicPeak"
},
"acceptInvite": {
"title": "Konto einrichten",
"invalidToken": "Dieser Einladungslink ist ungültig oder abgelaufen. Bitte kontaktieren Sie Ihren Fotografen für eine neue Einladung.",
"emailWillBe": "Ihre Konto-E-Mail wird ",
"invitedBy": ", eingeladen von ",
"name": "Ihr Name",
"nameRequired": "Bitte geben Sie Ihren Namen ein",
"password": "Passwort wählen",
"confirm": "Passwort bestätigen",
"passwordTooShort": "Passwort muss mindestens 8 Zeichen haben",
"passwordsMismatch": "Passwörter stimmen nicht überein",
"passwordHint": "Mindestens 8 Zeichen, mit einem Großbuchstaben und einer Zahl.",
"submit": "Konto erstellen",
"successToast": "Konto erstellt — bitte anmelden.",
"alreadyExists": "Es existiert bereits ein Konto mit dieser E-Mail. Bitte melden Sie sich stattdessen an.",
"invalidSubmission": "Konto konnte nicht erstellt werden.",
"generalError": "Konto konnte nicht erstellt werden. Bitte erneut versuchen.",
"subtitle": "Bestätige oder ergänze deine Daten. Du kannst alles später im Profil bearbeiten.",
"displayName": "Anzeigename",
"section": {
"personal": "Persönlich",
"contact": "Kontakt & Firma (optional)",
"address": "Rechnungsadresse (optional)",
"password": "Passwort wählen"
}
},
"dashboard": {
"title": "Deine Galerien",
"subtitle": "Klicke auf eine Galerie, um sie zu öffnen. Der Download-Button bündelt alle Fotos als ZIP.",
"loadError": "Galerien konnten nicht geladen werden. Bitte erneut versuchen.",
"emptyTitle": "Noch keine Galerien",
"emptyBody": "Sobald Ihr Fotograf Sie einer Galerie zuweist, erscheint sie hier.",
"openAria": "Galerie {{name}} öffnen",
"opening": "Wird geöffnet…",
"expiresOn": "Läuft ab am {{date}}",
"expiredOn": "Abgelaufen am {{date}}",
"eventExpired": "Diese Galerie ist abgelaufen.",
"eventForbidden": "Sie haben keinen Zugriff mehr auf diese Galerie.",
"openError": "Galerie konnte nicht geöffnet werden. Bitte erneut versuchen.",
"download": "Herunterladen",
"preparingDownload": "Wird vorbereitet…",
"quickDownloadAria": "Alle Fotos für {{name}} herunterladen",
"downloadStarted": "Download gestartet für {{name}}",
"downloadError": "Download konnte nicht gestartet werden. Bitte erneut versuchen.",
"sortLabel": "Sortieren nach",
"sortNewest": "Neueste zuerst",
"sortOldest": "Älteste zuerst",
"sortName": "Nach Name",
"open": "Öffnen"
},
"layout": {
"greeting": "Hallo, {{name}}"
},
"nav": {
"galleries": "Galerien",
"calendar": "Kalender",
"quotes": "Angebote",
"bills": "Rechnungen",
"profile": "Profil",
"soon": "Bald"
},
"comingSoon": {
"tag": "Demnächst"
},
"calendar": {
"title": "Kalender",
"body": "Bevorstehende Termine, Liefertermine für Galerien und weitere Shoot-Ereignisse landen hier. Wir geben Bescheid, sobald es soweit ist."
},
"quotes": {
"title": "Angebote",
"body": "Angebote für kommende Shoots an einem Ort prüfen und annehmen. Wir bauen das noch — bis dahin schickt dir dein Fotograf Angebote wie gewohnt."
},
"bills": {
"title": "Rechnungen",
"body": "Rechnungen und Zahlungshistorie zu deinen Shoots findest du hier. Wir senden eine E-Mail, sobald es live ist."
},
"profile": {
"title": "Kundenprofil",
"subtitle": "Halte Kontakt- und Rechnungsdaten aktuell — sie erscheinen auf Angeboten und Rechnungen, sobald diese Funktionen live sind.",
"savedToast": "Profil gespeichert",
"saveError": "Profil konnte nicht gespeichert werden.",
"loadError": "Profil konnte nicht geladen werden.",
"save": "Änderungen speichern",
"salutation": {
"none": "— Keine Angabe —",
"herr": "Herr",
"frau": "Frau",
"mx": "Mx",
"dr": "Dr."
},
"section": {
"personal": "Persönliche Angaben",
"contact": "Kontakt & Firma",
"address": "Rechnungsadresse",
"password": "Passwort ändern"
},
"field": {
"email": "E-Mail (Login)",
"emailHint": "Wende dich an deinen Fotografen, wenn du deine Login-E-Mail ändern möchtest.",
"salutation": "Anrede",
"firstName": "Vorname",
"lastName": "Nachname",
"displayName": "Anzeigename",
"displayNameHint": "So begrüßen wir dich im Dashboard.",
"phone": "Telefon",
"companyName": "Firmenname",
"vatId": "USt-IdNr.",
"addressLine1": "Adresszeile 1",
"addressLine2": "Adresszeile 2",
"postalCode": "Postleitzahl",
"city": "Stadt",
"state": "Bundesland / Region",
"countryCode": "Land"
},
"password": {
"current": "Aktuelles Passwort",
"next": "Neues Passwort",
"confirm": "Neues Passwort bestätigen",
"submit": "Passwort aktualisieren",
"hint": "Mindestens 8 Zeichen, ein Großbuchstabe und eine Ziffer.",
"currentRequired": "Aktuelles Passwort eingeben",
"tooShort": "Mindestens 8 Zeichen",
"mismatch": "Passwörter stimmen nicht überein",
"wrong": "Aktuelles Passwort ist falsch",
"savedToast": "Passwort aktualisiert",
"error": "Passwort konnte nicht geändert werden"
}
},
"resetPassword": {
"title": "Passwort zurücksetzen",
"invalidToken": "Dieser Link ist ungültig oder abgelaufen. Bitte fordere bei deinem Fotografen einen neuen an.",
"forEmail": "Neues Passwort wird gesetzt für ",
"password": "Neues Passwort",
"confirm": "Neues Passwort bestätigen",
"submit": "Passwort aktualisieren",
"hint": "Mindestens 8 Zeichen, ein Großbuchstabe und eine Ziffer.",
"tooShort": "Passwort muss mindestens 8 Zeichen lang sein",
"mismatch": "Passwörter stimmen nicht überein",
"successToast": "Passwort aktualisiert. Bitte einloggen.",
"invalidSubmission": "Passwort konnte nicht aktualisiert werden.",
"generalError": "Passwort konnte nicht aktualisiert werden. Bitte erneut versuchen."
}
},
"customers": {
"pageTitle": "Kunden",
"pageSubtitle": "Wiederkehrende Kundenkonten, die sich unter /customer/login anmelden können.",
"unnamed": "Unbenannt",
"empty": "Noch keine Kunden. Klicken Sie auf „Kunden einladen“, um einen hinzuzufügen.",
"loadError": "Kunden konnten nicht geladen werden",
"loadInvitationsError": "Einladungen konnten nicht geladen werden",
"tabs": {
"customers": "Kunden",
"invitations": "Einladungen"
},
"search": {
"placeholder": "Nach E-Mail, Name oder Firma suchen"
},
"table": {
"name": "Name",
"email": "E-Mail",
"company": "Firma",
"eventCount": "Events",
"lastLogin": "Letzte Anmeldung",
"status": "Status"
},
"status": {
"active": "Aktiv",
"inactive": "Deaktiviert"
},
"invite": {
"button": "Kunden einladen",
"title": "Kunden einladen",
"description": "Der Kunde erhält eine E-Mail mit einem Link zum Einrichten des Kontos. Nach Annahme können Sie ihn Events zuordnen.",
"email": "E-Mail",
"submit": "Einladung senden",
"success": "Einladung gesendet",
"error": "Einladung konnte nicht gesendet werden.",
"conflict": "Ein Kunde mit dieser E-Mail existiert bereits oder hat eine offene Einladung.",
"invalidEmail": "Bitte geben Sie eine gültige E-Mail ein",
"showPrefill": "+ Kontaktdaten hinzufügen (optional)",
"hidePrefill": " Kontaktdaten ausblenden",
"prefillHint": "Alles, was du ausfüllst, wird auf der Anmeldeseite des Kunden vorausgefüllt — er kann es weiterhin bearbeiten."
},
"invitations": {
"empty": "Keine offenen Einladungen.",
"email": "E-Mail",
"invitedBy": "Eingeladen von",
"expiresAt": "Läuft ab",
"createdAt": "Erstellt",
"cancel": "Stornieren"
},
"deactivate": {
"button": "Deaktivieren",
"title": "Kunde deaktivieren?",
"body": "Der Kunde kann sich nicht mehr anmelden. Sie können ihn später erneut einladen.",
"success": "Kunde deaktiviert",
"error": "Kunde konnte nicht deaktiviert werden"
},
"cancelInvitation": {
"title": "Einladung stornieren?",
"body": "Der Einladungslink funktioniert sofort nicht mehr.",
"success": "Einladung storniert",
"error": "Einladung konnte nicht storniert werden"
},
"detail": {
"loadError": "Kunde konnte nicht geladen werden",
"saved": "Kunde gespeichert",
"saveError": "Änderungen konnten nicht gespeichert werden.",
"emailConflict": "Diese E-Mail wird bereits von einem anderen Kunden verwendet.",
"save": "Änderungen speichern",
"expires": "läuft ab",
"accountSection": "Konto",
"personalSection": "Persönliche Daten",
"billingSection": "Adresse & Rechnung",
"notesSection": "Interne Notizen",
"eventsSection": "Zugewiesene Events",
"noEvents": "Noch keinem Event zugewiesen. Fügen Sie diesen Kunden über das Event-Formular hinzu.",
"email": "E-Mail",
"preferredLanguage": "Bevorzugte Sprache",
"salutation": "Anrede",
"salutationNone": "—",
"firstName": "Vorname",
"lastName": "Nachname",
"displayName": "Anzeigename",
"phone": "Telefon",
"company": "Firma",
"billingEmail": "Rechnungs-E-Mail",
"vatId": "USt-IdNr.",
"addressLine1": "Adresse Zeile 1",
"addressLine2": "Adresse Zeile 2",
"postalCode": "Postleitzahl",
"city": "Stadt",
"state": "Bundesland / Region",
"countryCode": "Land (ISO-2)",
"notesHint": "Nur für Administratoren sichtbar. Wird dem Kunden nie gezeigt.",
"featuresSection": "Kundenfunktionen",
"featuresHint": "Pro-Kunde-Überschreibungen für die Kundenoberflächen-Tabs. Die globalen Schalter in Einstellungen → Kundenoberfläche sind der Master-Schalter — wenn global AUS, sieht niemand den Tab, unabhängig von der Einstellung hier. Standard ist AN, schalte einen Eintrag AUS, um diesen Tab für diesen Kunden auszublenden.",
"passwordSection": "Kontoaktionen",
"passwordHint": "Sendet einen einmalig nutzbaren Reset-Link (7 Tage gültig) an die E-Mail-Adresse des Kunden. Das aktuelle Passwort bleibt gültig, bis der Kunde den Link öffnet und ein neues setzt.",
"passwordReset": {
"button": "Passwort-Reset-E-Mail senden",
"success": "Passwort-Reset-E-Mail gesendet",
"error": "Passwort-Reset konnte nicht gesendet werden",
"inactive": "Aktiviere den Kunden, bevor du einen Reset sendest."
}
},
"reactivate": {
"button": "Reaktivieren",
"success": "Kunde reaktiviert",
"error": "Kunde konnte nicht reaktiviert werden"
},
"erase": {
"button": "Kundendaten löschen",
"title": "Kundendaten löschen?",
"body": "Entfernt Name, E-Mail, Telefon, Adresse, Firma und Login-Daten des Kunden. Der Kontoeintrag bleibt, damit historische Galerie-Zugriffe und Audit-Logs ihn weiter referenzieren können. Dies ist unwiderruflich — die Daten können danach nicht wiederhergestellt werden.",
"confirm": "Endgültig löschen",
"confirmInFlight": "Lösche…",
"success": "Kunde gelöscht",
"error": "Kunde konnte nicht gelöscht werden"
}
}
}
+293 -2
View File
@@ -167,7 +167,8 @@
"backup": "Backup & Restore",
"cmsPages": "CMS Pages",
"users": "Users",
"calendar": "Calendar"
"calendar": "Calendar",
"customers": "Customers"
},
"archives": {
"title": "Archives",
@@ -1085,7 +1086,8 @@
"communication": "Communication",
"scheduling": "Scheduling",
"sales": "Sales",
"insights": "Insights & Access"
"insights": "Insights & Access",
"customers": "Customers"
},
"status": {
"stable": "stable",
@@ -1131,6 +1133,10 @@
"title": "User Management",
"description": "Multi-admin support with role-based permissions. Turn off if you're a single-operator studio.",
"warning": "Existing user accounts stay valid; the admin UI for managing them will be hidden until you re-enable this."
},
"customerPortal": {
"title": "Customer portal",
"description": "Persistent customer logins. Recurring clients see all their assigned galleries from one place — no per-event passwords. Foundation for Calendar / Quotes / Bills (which only render in the customer dashboard when this is on)."
}
}
},
@@ -2540,5 +2546,290 @@
"movedToCategory_other": "{{count}} photos moved to {{category}}",
"moveToCategoryFailed": "Failed to move photos to category",
"moveToCategory": "Move to Category"
},
"customer": {
"login": {
"title": "Customer login",
"subtitle": "Access all of your photo galleries in one place.",
"email": "Email",
"password": "Password",
"emailRequired": "Email is required",
"invalidEmail": "Please enter a valid email",
"passwordRequired": "Password is required",
"showPassword": "Show password",
"hidePassword": "Hide password",
"signIn": "Sign in",
"loginSuccess": "Welcome back!",
"invalidCredentials": "Invalid email or password",
"tooManyAttempts": "Too many attempts — please try again later.",
"networkError": "Could not reach the server. Please try again.",
"generalError": "Login failed. Please try again.",
"acceptedToast": "Account ready — please log in.",
"adminHint": "Looking for the admin panel? Visit /admin/login.",
"emailPlaceholder": "[email protected]",
"passwordPlaceholder": "Your password",
"needHelp": "Need help?",
"poweredBy": "Powered by PicPeak"
},
"acceptInvite": {
"title": "Set up your account",
"invalidToken": "This invitation link is invalid or has expired. Please contact your photographer for a new invitation.",
"emailWillBe": "Your account email will be ",
"invitedBy": ", invited by ",
"name": "Your name",
"nameRequired": "Please enter your name",
"password": "Choose a password",
"confirm": "Confirm password",
"passwordTooShort": "Password must be at least 8 characters",
"passwordsMismatch": "Passwords do not match",
"passwordHint": "At least 8 characters, with one uppercase letter and one number.",
"submit": "Create account",
"successToast": "Account created — please log in.",
"alreadyExists": "An account with this email already exists. Please log in instead.",
"invalidSubmission": "Could not create your account.",
"generalError": "Could not create your account. Please try again.",
"subtitle": "Confirm or fill in your details. You can edit anything from the profile page later.",
"displayName": "Display name",
"section": {
"personal": "Personal",
"contact": "Contact & business (optional)",
"address": "Billing address (optional)",
"password": "Choose a password"
}
},
"dashboard": {
"title": "Your galleries",
"subtitle": "Click a gallery to open it. The Download button bundles every photo as a zip.",
"loadError": "Could not load your galleries. Please try again.",
"emptyTitle": "No galleries yet",
"emptyBody": "Once your photographer assigns you to a gallery, it will appear here.",
"openAria": "Open gallery {{name}}",
"opening": "Opening…",
"expiresOn": "Expires {{date}}",
"expiredOn": "Expired {{date}}",
"eventExpired": "This gallery has expired.",
"eventForbidden": "You no longer have access to this gallery.",
"openError": "Could not open this gallery. Please try again.",
"download": "Download",
"preparingDownload": "Preparing…",
"quickDownloadAria": "Download all photos for {{name}}",
"downloadStarted": "Download started for {{name}}",
"downloadError": "Could not start the download. Please try again.",
"sortLabel": "Sort by",
"sortNewest": "Newest first",
"sortOldest": "Oldest first",
"sortName": "By name",
"open": "Open"
},
"layout": {
"greeting": "Hi, {{name}}"
},
"nav": {
"galleries": "Galleries",
"calendar": "Calendar",
"quotes": "Quotes",
"bills": "Bills",
"profile": "Profile",
"soon": "Soon"
},
"comingSoon": {
"tag": "Coming soon"
},
"calendar": {
"title": "Calendar",
"body": "Upcoming sessions, gallery delivery dates, and other shoot-related events will land here. We'll let you know when it's ready."
},
"quotes": {
"title": "Quotes",
"body": "Review and accept quotes for upcoming shoots in one place. We're still building this — for now, your photographer will keep sending quotes the usual way."
},
"bills": {
"title": "Bills",
"body": "Invoices and payment history for your sessions will be available here. We'll send you an email when this is live."
},
"profile": {
"title": "Customer profile",
"subtitle": "Keep your contact and billing details up to date — they're shown on quotes and invoices once those features go live.",
"savedToast": "Profile saved",
"saveError": "Could not save profile.",
"loadError": "Could not load your profile.",
"save": "Save changes",
"salutation": {
"none": "— Not specified —",
"herr": "Mr.",
"frau": "Ms.",
"mx": "Mx",
"dr": "Dr."
},
"section": {
"personal": "Personal information",
"contact": "Contact & business",
"address": "Billing address",
"password": "Change password"
},
"field": {
"email": "Email (login)",
"emailHint": "Contact your photographer if you need to change your login email.",
"salutation": "Salutation",
"firstName": "First name",
"lastName": "Last name",
"displayName": "Display name",
"displayNameHint": "How we greet you in the dashboard.",
"phone": "Phone",
"companyName": "Company name",
"vatId": "VAT ID",
"addressLine1": "Address line 1",
"addressLine2": "Address line 2",
"postalCode": "Postal code",
"city": "City",
"state": "State / region",
"countryCode": "Country"
},
"password": {
"current": "Current password",
"next": "New password",
"confirm": "Confirm new password",
"submit": "Update password",
"hint": "At least 8 characters with one uppercase letter and one number.",
"currentRequired": "Enter your current password",
"tooShort": "At least 8 characters",
"mismatch": "Passwords do not match",
"wrong": "Current password is incorrect",
"savedToast": "Password updated",
"error": "Could not change password"
}
},
"resetPassword": {
"title": "Reset your password",
"invalidToken": "This reset link is invalid or has expired. Please ask your photographer to send a new one.",
"forEmail": "Setting a new password for ",
"password": "New password",
"confirm": "Confirm new password",
"submit": "Update password",
"hint": "At least 8 characters with one uppercase letter and one number.",
"tooShort": "Password must be at least 8 characters",
"mismatch": "Passwords do not match",
"successToast": "Password updated. Please log in.",
"invalidSubmission": "Could not update your password.",
"generalError": "Could not update your password. Please try again."
}
},
"customers": {
"pageTitle": "Customers",
"pageSubtitle": "Recurring customer accounts that can log in at /customer/login.",
"unnamed": "Unnamed",
"empty": "No customers yet. Click \"Invite customer\" to add one.",
"loadError": "Could not load customers",
"loadInvitationsError": "Could not load invitations",
"tabs": {
"customers": "Customers",
"invitations": "Invitations"
},
"search": {
"placeholder": "Search by email, name, or company"
},
"table": {
"name": "Name",
"email": "Email",
"company": "Company",
"eventCount": "Events",
"lastLogin": "Last login",
"status": "Status"
},
"status": {
"active": "Active",
"inactive": "Deactivated"
},
"invite": {
"button": "Invite customer",
"title": "Invite a customer",
"description": "They will receive an email with a link to set up their account. Once they have accepted, you can assign them to events.",
"email": "Email",
"submit": "Send invitation",
"success": "Invitation sent",
"error": "Could not send invitation.",
"conflict": "A customer with this email already exists or has a pending invitation.",
"invalidEmail": "Please enter a valid email",
"showPrefill": "+ Add contact details (optional)",
"hidePrefill": " Hide contact details",
"prefillHint": "Anything you fill in will be pre-populated on the customer's sign-up page — they can still edit it."
},
"invitations": {
"empty": "No pending invitations.",
"email": "Email",
"invitedBy": "Invited by",
"expiresAt": "Expires",
"createdAt": "Created",
"cancel": "Cancel"
},
"deactivate": {
"button": "Deactivate",
"title": "Deactivate customer?",
"body": "They will no longer be able to log in. You can re-invite them later.",
"success": "Customer deactivated",
"error": "Could not deactivate customer"
},
"cancelInvitation": {
"title": "Cancel invitation?",
"body": "The invitation link will stop working immediately.",
"success": "Invitation cancelled",
"error": "Could not cancel invitation"
},
"detail": {
"loadError": "Could not load customer",
"saved": "Customer saved",
"saveError": "Could not save changes.",
"emailConflict": "That email is already in use by another customer.",
"save": "Save changes",
"expires": "expires",
"accountSection": "Account",
"personalSection": "Personal information",
"billingSection": "Address & billing",
"notesSection": "Internal notes",
"eventsSection": "Assigned events",
"noEvents": "Not assigned to any events yet. Add this customer to an event from the event form.",
"email": "Email",
"preferredLanguage": "Preferred language",
"salutation": "Salutation",
"salutationNone": "—",
"firstName": "First name",
"lastName": "Last name",
"displayName": "Display name",
"phone": "Phone",
"company": "Company",
"billingEmail": "Billing email",
"vatId": "VAT / tax ID",
"addressLine1": "Address line 1",
"addressLine2": "Address line 2",
"postalCode": "Postal code",
"city": "City",
"state": "State / region",
"countryCode": "Country (ISO 2)",
"notesHint": "Visible only to admins. Never shown to the customer.",
"featuresSection": "Customer features",
"featuresHint": "Per-customer overrides for the customer-surface tabs. The global toggles in Settings → Customer Surface are the master switch — when global is OFF nobody sees the tab, regardless of what you set here. Defaults are ON, so flip a switch OFF to hide a tab for this specific customer.",
"passwordSection": "Account actions",
"passwordHint": "Sends a 7-day single-use reset link to the customer's email. The customer's current password keeps working until they click the link and set a new one.",
"passwordReset": {
"button": "Send password reset email",
"success": "Password reset email sent",
"error": "Could not send password reset",
"inactive": "Reactivate the customer before sending a reset."
}
},
"reactivate": {
"button": "Reactivate",
"success": "Customer reactivated",
"error": "Could not reactivate customer"
},
"erase": {
"button": "Erase customer data",
"title": "Erase customer data?",
"body": "Removes the customer's name, email, phone, address, company and credentials. The account row stays so historical event-access records and audit logs still reference it. This is irreversible — you cannot restore the data afterwards.",
"confirm": "Erase permanently",
"confirmInFlight": "Erasing…",
"success": "Customer erased",
"error": "Could not erase customer"
}
}
}
+287 -1
View File
@@ -167,7 +167,8 @@
"backup": "Back-up en herstel",
"cmsPages": "CMS-pagina's",
"users": "Gebruikers",
"calendar": "Agenda"
"calendar": "Agenda",
"customers": "Customers"
},
"archives": {
"title": "Archieven",
@@ -2540,5 +2541,290 @@
"movedToCategory_other": "{{count}} foto's verplaatst naar {{category}}",
"moveToCategoryFailed": "Verplaatsen naar categorie mislukt",
"moveToCategory": "Naar categorie verplaatsen"
},
"customer": {
"login": {
"title": "Klantlogin",
"subtitle": "Bekijk al uw fotogalerieën op één plek.",
"email": "E-mail",
"password": "Wachtwoord",
"emailRequired": "E-mail is verplicht",
"invalidEmail": "Voer een geldig e-mailadres in",
"passwordRequired": "Wachtwoord is verplicht",
"showPassword": "Wachtwoord tonen",
"hidePassword": "Wachtwoord verbergen",
"signIn": "Inloggen",
"loginSuccess": "Welkom terug!",
"invalidCredentials": "Ongeldig e-mailadres of wachtwoord",
"tooManyAttempts": "Te veel pogingen — probeer het later opnieuw.",
"networkError": "Kan de server niet bereiken. Probeer het opnieuw.",
"generalError": "Inloggen mislukt. Probeer het opnieuw.",
"acceptedToast": "Account klaar — log nu in.",
"adminHint": "Zoekt u het beheerderspaneel? Ga naar /admin/login.",
"emailPlaceholder": "[email protected]",
"passwordPlaceholder": "Your password",
"needHelp": "Need help?",
"poweredBy": "Powered by PicPeak"
},
"acceptInvite": {
"title": "Account instellen",
"invalidToken": "Deze uitnodigingslink is ongeldig of verlopen. Neem contact op met uw fotograaf voor een nieuwe uitnodiging.",
"emailWillBe": "Uw account-e-mail wordt ",
"invitedBy": ", uitgenodigd door ",
"name": "Uw naam",
"nameRequired": "Voer uw naam in",
"password": "Kies een wachtwoord",
"confirm": "Wachtwoord bevestigen",
"passwordTooShort": "Wachtwoord moet minimaal 8 tekens bevatten",
"passwordsMismatch": "Wachtwoorden komen niet overeen",
"passwordHint": "Minimaal 8 tekens, met één hoofdletter en één cijfer.",
"submit": "Account aanmaken",
"successToast": "Account aangemaakt — log nu in.",
"alreadyExists": "Er bestaat al een account met dit e-mailadres. Log in plaats daarvan in.",
"invalidSubmission": "Account kon niet worden aangemaakt.",
"generalError": "Account kon niet worden aangemaakt. Probeer het opnieuw.",
"subtitle": "Confirm or fill in your details. You can edit anything from the profile page later.",
"displayName": "Display name",
"section": {
"personal": "Personal",
"contact": "Contact & business (optional)",
"address": "Billing address (optional)",
"password": "Choose a password"
}
},
"dashboard": {
"title": "Your galleries",
"subtitle": "Click a gallery to open it. The Download button bundles every photo as a zip.",
"loadError": "Uw galerieën konden niet worden geladen. Probeer het opnieuw.",
"emptyTitle": "Nog geen galerieën",
"emptyBody": "Zodra uw fotograaf u aan een galerie toewijst, verschijnt deze hier.",
"openAria": "Galerie {{name}} openen",
"opening": "Openen…",
"expiresOn": "Verloopt op {{date}}",
"expiredOn": "Verlopen op {{date}}",
"eventExpired": "Deze galerie is verlopen.",
"eventForbidden": "U heeft geen toegang meer tot deze galerie.",
"openError": "Galerie kon niet worden geopend. Probeer het opnieuw.",
"download": "Downloaden",
"preparingDownload": "Voorbereiden…",
"quickDownloadAria": "Alle foto's voor {{name}} downloaden",
"downloadStarted": "Download gestart voor {{name}}",
"downloadError": "Download kon niet worden gestart. Probeer het opnieuw.",
"sortLabel": "Sort by",
"sortNewest": "Newest first",
"sortOldest": "Oldest first",
"sortName": "By name",
"open": "Open"
},
"layout": {
"greeting": "Hallo, {{name}}"
},
"nav": {
"galleries": "Galleries",
"calendar": "Calendar",
"quotes": "Quotes",
"bills": "Bills",
"profile": "Profile",
"soon": "Soon"
},
"comingSoon": {
"tag": "Coming soon"
},
"calendar": {
"title": "Calendar",
"body": "Upcoming sessions, gallery delivery dates, and other shoot-related events will land here. We'll let you know when it's ready."
},
"quotes": {
"title": "Quotes",
"body": "Review and accept quotes for upcoming shoots in one place. We're still building this — for now, your photographer will keep sending quotes the usual way."
},
"bills": {
"title": "Bills",
"body": "Invoices and payment history for your sessions will be available here. We'll send you an email when this is live."
},
"profile": {
"title": "Customer profile",
"subtitle": "Keep your contact and billing details up to date — they're shown on quotes and invoices once those features go live.",
"savedToast": "Profile saved",
"saveError": "Could not save profile.",
"loadError": "Could not load your profile.",
"save": "Save changes",
"salutation": {
"none": "— Not specified —",
"herr": "Mr.",
"frau": "Ms.",
"mx": "Mx",
"dr": "Dr."
},
"section": {
"personal": "Personal information",
"contact": "Contact & business",
"address": "Billing address",
"password": "Change password"
},
"field": {
"email": "Email (login)",
"emailHint": "Contact your photographer if you need to change your login email.",
"salutation": "Salutation",
"firstName": "First name",
"lastName": "Last name",
"displayName": "Display name",
"displayNameHint": "How we greet you in the dashboard.",
"phone": "Phone",
"companyName": "Company name",
"vatId": "VAT ID",
"addressLine1": "Address line 1",
"addressLine2": "Address line 2",
"postalCode": "Postal code",
"city": "City",
"state": "State / region",
"countryCode": "Country"
},
"password": {
"current": "Current password",
"next": "New password",
"confirm": "Confirm new password",
"submit": "Update password",
"hint": "At least 8 characters with one uppercase letter and one number.",
"currentRequired": "Enter your current password",
"tooShort": "At least 8 characters",
"mismatch": "Passwords do not match",
"wrong": "Current password is incorrect",
"savedToast": "Password updated",
"error": "Could not change password"
}
},
"resetPassword": {
"title": "Reset your password",
"invalidToken": "This reset link is invalid or has expired. Please ask your photographer to send a new one.",
"forEmail": "Setting a new password for ",
"password": "New password",
"confirm": "Confirm new password",
"submit": "Update password",
"hint": "At least 8 characters with one uppercase letter and one number.",
"tooShort": "Password must be at least 8 characters",
"mismatch": "Passwords do not match",
"successToast": "Password updated. Please log in.",
"invalidSubmission": "Could not update your password.",
"generalError": "Could not update your password. Please try again."
}
},
"customers": {
"pageTitle": "Klanten",
"pageSubtitle": "Terugkerende klantaccounts die kunnen inloggen op /customer/login.",
"unnamed": "Naamloos",
"empty": "Nog geen klanten. Klik op \"Klant uitnodigen\" om er een toe te voegen.",
"loadError": "Klanten konden niet worden geladen",
"loadInvitationsError": "Uitnodigingen konden niet worden geladen",
"tabs": {
"customers": "Klanten",
"invitations": "Uitnodigingen"
},
"search": {
"placeholder": "Zoeken op e-mail, naam of bedrijf"
},
"table": {
"name": "Naam",
"email": "E-mail",
"company": "Bedrijf",
"eventCount": "Evenementen",
"lastLogin": "Laatste login",
"status": "Status"
},
"status": {
"active": "Actief",
"inactive": "Gedeactiveerd"
},
"invite": {
"button": "Klant uitnodigen",
"title": "Een klant uitnodigen",
"description": "Zij ontvangen een e-mail met een link om hun account in te stellen. Zodra zij hebben geaccepteerd, kunt u hen toewijzen aan evenementen.",
"email": "E-mail",
"submit": "Uitnodiging sturen",
"success": "Uitnodiging verzonden",
"error": "Uitnodiging kon niet worden verzonden.",
"conflict": "Er bestaat al een klant met dit e-mailadres of er is een openstaande uitnodiging.",
"invalidEmail": "Voer een geldig e-mailadres in",
"showPrefill": "+ Add contact details (optional)",
"hidePrefill": " Hide contact details",
"prefillHint": "Anything you fill in will be pre-populated on the customer's sign-up page — they can still edit it."
},
"invitations": {
"empty": "Geen openstaande uitnodigingen.",
"email": "E-mail",
"invitedBy": "Uitgenodigd door",
"expiresAt": "Verloopt",
"createdAt": "Aangemaakt",
"cancel": "Annuleren"
},
"deactivate": {
"button": "Deactiveren",
"title": "Klant deactiveren?",
"body": "Zij kunnen niet meer inloggen. U kunt hen later opnieuw uitnodigen.",
"success": "Klant gedeactiveerd",
"error": "Klant kon niet worden gedeactiveerd"
},
"cancelInvitation": {
"title": "Uitnodiging annuleren?",
"body": "De uitnodigingslink stopt direct met werken.",
"success": "Uitnodiging geannuleerd",
"error": "Uitnodiging kon niet worden geannuleerd"
},
"detail": {
"loadError": "Klant kon niet worden geladen",
"saved": "Klant opgeslagen",
"saveError": "Wijzigingen konden niet worden opgeslagen.",
"emailConflict": "Dat e-mailadres is al in gebruik door een andere klant.",
"save": "Wijzigingen opslaan",
"expires": "verloopt",
"accountSection": "Account",
"personalSection": "Persoonlijke gegevens",
"billingSection": "Adres & facturering",
"notesSection": "Interne notities",
"eventsSection": "Toegewezen evenementen",
"noEvents": "Nog niet toegewezen aan evenementen. Voeg deze klant toe aan een evenement via het evenementformulier.",
"email": "E-mail",
"preferredLanguage": "Voorkeurstaal",
"salutation": "Aanhef",
"salutationNone": "—",
"firstName": "Voornaam",
"lastName": "Achternaam",
"displayName": "Weergavenaam",
"phone": "Telefoon",
"company": "Bedrijf",
"billingEmail": "Facturatie-e-mail",
"vatId": "BTW / fiscaal nummer",
"addressLine1": "Adresregel 1",
"addressLine2": "Adresregel 2",
"postalCode": "Postcode",
"city": "Stad",
"state": "Provincie / regio",
"countryCode": "Land (ISO 2)",
"notesHint": "Alleen zichtbaar voor beheerders. Wordt nooit aan de klant getoond.",
"featuresSection": "Customer features",
"featuresHint": "Per-customer overrides for the customer-surface tabs. The global toggles in Settings → Customer Surface are the master switch — when global is OFF nobody sees the tab, regardless of what you set here. Defaults are ON, so flip a switch OFF to hide a tab for this specific customer.",
"passwordSection": "Account actions",
"passwordHint": "Sends a 7-day single-use reset link to the customer's email. The customer's current password keeps working until they click the link and set a new one.",
"passwordReset": {
"button": "Send password reset email",
"success": "Password reset email sent",
"error": "Could not send password reset",
"inactive": "Reactivate the customer before sending a reset."
}
},
"reactivate": {
"button": "Reactivate",
"success": "Customer reactivated",
"error": "Could not reactivate customer"
},
"erase": {
"button": "Erase customer data",
"title": "Erase customer data?",
"body": "Removes the customer's name, email, phone, address, company and credentials. The account row stays so historical event-access records and audit logs still reference it. This is irreversible — you cannot restore the data afterwards.",
"confirm": "Erase permanently",
"confirmInFlight": "Erasing…",
"success": "Customer erased",
"error": "Could not erase customer"
}
}
}
+287 -1
View File
@@ -170,7 +170,8 @@
"backup": "Backup e Restauração",
"cmsPages": "Páginas CMS",
"users": "Utilizadores",
"calendar": "Calendário"
"calendar": "Calendário",
"customers": "Customers"
},
"archives": {
"title": "Arquivos",
@@ -2573,5 +2574,290 @@
"movedToCategory_other": "{{count}} fotos movidas para {{category}}",
"moveToCategoryFailed": "Falha ao mover fotos para categoria",
"moveToCategory": "Mover para categoria"
},
"customer": {
"login": {
"title": "Login de cliente",
"subtitle": "Acesse todas as suas galerias de fotos em um só lugar.",
"email": "E-mail",
"password": "Senha",
"emailRequired": "E-mail é obrigatório",
"invalidEmail": "Insira um e-mail válido",
"passwordRequired": "Senha é obrigatória",
"showPassword": "Mostrar senha",
"hidePassword": "Ocultar senha",
"signIn": "Entrar",
"loginSuccess": "Bem-vindo de volta!",
"invalidCredentials": "E-mail ou senha inválidos",
"tooManyAttempts": "Muitas tentativas — tente novamente mais tarde.",
"networkError": "Não foi possível conectar ao servidor. Tente novamente.",
"generalError": "Falha no login. Tente novamente.",
"acceptedToast": "Conta pronta — faça login.",
"adminHint": "Procurando o painel de administração? Acesse /admin/login.",
"emailPlaceholder": "[email protected]",
"passwordPlaceholder": "Your password",
"needHelp": "Need help?",
"poweredBy": "Powered by PicPeak"
},
"acceptInvite": {
"title": "Configurar sua conta",
"invalidToken": "Este link de convite é inválido ou expirou. Entre em contato com seu fotógrafo para um novo convite.",
"emailWillBe": "O e-mail da sua conta será ",
"invitedBy": ", convidado por ",
"name": "Seu nome",
"nameRequired": "Insira seu nome",
"password": "Escolha uma senha",
"confirm": "Confirmar senha",
"passwordTooShort": "A senha deve ter no mínimo 8 caracteres",
"passwordsMismatch": "As senhas não coincidem",
"passwordHint": "Mínimo de 8 caracteres, com uma letra maiúscula e um número.",
"submit": "Criar conta",
"successToast": "Conta criada — faça login.",
"alreadyExists": "Já existe uma conta com este e-mail. Faça login.",
"invalidSubmission": "Não foi possível criar sua conta.",
"generalError": "Não foi possível criar sua conta. Tente novamente.",
"subtitle": "Confirm or fill in your details. You can edit anything from the profile page later.",
"displayName": "Display name",
"section": {
"personal": "Personal",
"contact": "Contact & business (optional)",
"address": "Billing address (optional)",
"password": "Choose a password"
}
},
"dashboard": {
"title": "Your galleries",
"subtitle": "Click a gallery to open it. The Download button bundles every photo as a zip.",
"loadError": "Não foi possível carregar suas galerias. Tente novamente.",
"emptyTitle": "Ainda não há galerias",
"emptyBody": "Assim que seu fotógrafo atribuir você a uma galeria, ela aparecerá aqui.",
"openAria": "Abrir galeria {{name}}",
"opening": "Abrindo…",
"expiresOn": "Expira em {{date}}",
"expiredOn": "Expirou em {{date}}",
"eventExpired": "Esta galeria expirou.",
"eventForbidden": "Você não tem mais acesso a esta galeria.",
"openError": "Não foi possível abrir esta galeria. Tente novamente.",
"download": "Baixar",
"preparingDownload": "Preparando…",
"quickDownloadAria": "Baixar todas as fotos de {{name}}",
"downloadStarted": "Download iniciado para {{name}}",
"downloadError": "Não foi possível iniciar o download. Tente novamente.",
"sortLabel": "Sort by",
"sortNewest": "Newest first",
"sortOldest": "Oldest first",
"sortName": "By name",
"open": "Open"
},
"layout": {
"greeting": "Olá, {{name}}"
},
"nav": {
"galleries": "Galleries",
"calendar": "Calendar",
"quotes": "Quotes",
"bills": "Bills",
"profile": "Profile",
"soon": "Soon"
},
"comingSoon": {
"tag": "Coming soon"
},
"calendar": {
"title": "Calendar",
"body": "Upcoming sessions, gallery delivery dates, and other shoot-related events will land here. We'll let you know when it's ready."
},
"quotes": {
"title": "Quotes",
"body": "Review and accept quotes for upcoming shoots in one place. We're still building this — for now, your photographer will keep sending quotes the usual way."
},
"bills": {
"title": "Bills",
"body": "Invoices and payment history for your sessions will be available here. We'll send you an email when this is live."
},
"profile": {
"title": "Customer profile",
"subtitle": "Keep your contact and billing details up to date — they're shown on quotes and invoices once those features go live.",
"savedToast": "Profile saved",
"saveError": "Could not save profile.",
"loadError": "Could not load your profile.",
"save": "Save changes",
"salutation": {
"none": "— Not specified —",
"herr": "Mr.",
"frau": "Ms.",
"mx": "Mx",
"dr": "Dr."
},
"section": {
"personal": "Personal information",
"contact": "Contact & business",
"address": "Billing address",
"password": "Change password"
},
"field": {
"email": "Email (login)",
"emailHint": "Contact your photographer if you need to change your login email.",
"salutation": "Salutation",
"firstName": "First name",
"lastName": "Last name",
"displayName": "Display name",
"displayNameHint": "How we greet you in the dashboard.",
"phone": "Phone",
"companyName": "Company name",
"vatId": "VAT ID",
"addressLine1": "Address line 1",
"addressLine2": "Address line 2",
"postalCode": "Postal code",
"city": "City",
"state": "State / region",
"countryCode": "Country"
},
"password": {
"current": "Current password",
"next": "New password",
"confirm": "Confirm new password",
"submit": "Update password",
"hint": "At least 8 characters with one uppercase letter and one number.",
"currentRequired": "Enter your current password",
"tooShort": "At least 8 characters",
"mismatch": "Passwords do not match",
"wrong": "Current password is incorrect",
"savedToast": "Password updated",
"error": "Could not change password"
}
},
"resetPassword": {
"title": "Reset your password",
"invalidToken": "This reset link is invalid or has expired. Please ask your photographer to send a new one.",
"forEmail": "Setting a new password for ",
"password": "New password",
"confirm": "Confirm new password",
"submit": "Update password",
"hint": "At least 8 characters with one uppercase letter and one number.",
"tooShort": "Password must be at least 8 characters",
"mismatch": "Passwords do not match",
"successToast": "Password updated. Please log in.",
"invalidSubmission": "Could not update your password.",
"generalError": "Could not update your password. Please try again."
}
},
"customers": {
"pageTitle": "Clientes",
"pageSubtitle": "Contas recorrentes de clientes que podem entrar em /customer/login.",
"unnamed": "Sem nome",
"empty": "Ainda não há clientes. Clique em \"Convidar cliente\" para adicionar um.",
"loadError": "Não foi possível carregar os clientes",
"loadInvitationsError": "Não foi possível carregar os convites",
"tabs": {
"customers": "Clientes",
"invitations": "Convites"
},
"search": {
"placeholder": "Pesquisar por e-mail, nome ou empresa"
},
"table": {
"name": "Nome",
"email": "E-mail",
"company": "Empresa",
"eventCount": "Eventos",
"lastLogin": "Último login",
"status": "Status"
},
"status": {
"active": "Ativo",
"inactive": "Desativado"
},
"invite": {
"button": "Convidar cliente",
"title": "Convidar um cliente",
"description": "Eles receberão um e-mail com um link para configurar a conta. Após aceitarem, você poderá atribuí-los a eventos.",
"email": "E-mail",
"submit": "Enviar convite",
"success": "Convite enviado",
"error": "Não foi possível enviar o convite.",
"conflict": "Já existe um cliente com este e-mail ou há um convite pendente.",
"invalidEmail": "Insira um e-mail válido",
"showPrefill": "+ Add contact details (optional)",
"hidePrefill": " Hide contact details",
"prefillHint": "Anything you fill in will be pre-populated on the customer's sign-up page — they can still edit it."
},
"invitations": {
"empty": "Nenhum convite pendente.",
"email": "E-mail",
"invitedBy": "Convidado por",
"expiresAt": "Expira",
"createdAt": "Criado",
"cancel": "Cancelar"
},
"deactivate": {
"button": "Desativar",
"title": "Desativar cliente?",
"body": "Eles não poderão mais fazer login. Você pode convidá-los novamente depois.",
"success": "Cliente desativado",
"error": "Não foi possível desativar o cliente"
},
"cancelInvitation": {
"title": "Cancelar convite?",
"body": "O link do convite deixará de funcionar imediatamente.",
"success": "Convite cancelado",
"error": "Não foi possível cancelar o convite"
},
"detail": {
"loadError": "Não foi possível carregar o cliente",
"saved": "Cliente salvo",
"saveError": "Não foi possível salvar as alterações.",
"emailConflict": "Este e-mail já está em uso por outro cliente.",
"save": "Salvar alterações",
"expires": "expira",
"accountSection": "Conta",
"personalSection": "Informações pessoais",
"billingSection": "Endereço & faturamento",
"notesSection": "Notas internas",
"eventsSection": "Eventos atribuídos",
"noEvents": "Ainda não atribuído a nenhum evento. Adicione este cliente a um evento pelo formulário de evento.",
"email": "E-mail",
"preferredLanguage": "Idioma preferido",
"salutation": "Saudação",
"salutationNone": "—",
"firstName": "Nome",
"lastName": "Sobrenome",
"displayName": "Nome de exibição",
"phone": "Telefone",
"company": "Empresa",
"billingEmail": "E-mail de faturamento",
"vatId": "NIF / CNPJ",
"addressLine1": "Endereço linha 1",
"addressLine2": "Endereço linha 2",
"postalCode": "CEP",
"city": "Cidade",
"state": "Estado / região",
"countryCode": "País (ISO 2)",
"notesHint": "Visível apenas para administradores. Nunca mostrado ao cliente.",
"featuresSection": "Customer features",
"featuresHint": "Per-customer overrides for the customer-surface tabs. The global toggles in Settings → Customer Surface are the master switch — when global is OFF nobody sees the tab, regardless of what you set here. Defaults are ON, so flip a switch OFF to hide a tab for this specific customer.",
"passwordSection": "Account actions",
"passwordHint": "Sends a 7-day single-use reset link to the customer's email. The customer's current password keeps working until they click the link and set a new one.",
"passwordReset": {
"button": "Send password reset email",
"success": "Password reset email sent",
"error": "Could not send password reset",
"inactive": "Reactivate the customer before sending a reset."
}
},
"reactivate": {
"button": "Reactivate",
"success": "Customer reactivated",
"error": "Could not reactivate customer"
},
"erase": {
"button": "Erase customer data",
"title": "Erase customer data?",
"body": "Removes the customer's name, email, phone, address, company and credentials. The account row stays so historical event-access records and audit logs still reference it. This is irreversible — you cannot restore the data afterwards.",
"confirm": "Erase permanently",
"confirmInFlight": "Erasing…",
"success": "Customer erased",
"error": "Could not erase customer"
}
}
}
+287 -1
View File
@@ -173,7 +173,8 @@
"backup": "Резервное копирование",
"cmsPages": "CMS-страницы",
"users": "Пользователи",
"calendar": "Календарь"
"calendar": "Календарь",
"customers": "Customers"
},
"archives": {
"title": "Архивы",
@@ -2606,5 +2607,290 @@
"movedToCategory_other": "{{count}} фото перемещено в {{category}}",
"moveToCategoryFailed": "Не удалось переместить фото в категорию",
"moveToCategory": "Переместить в категорию"
},
"customer": {
"login": {
"title": "Вход клиента",
"subtitle": "Доступ ко всем вашим фотогалереям в одном месте.",
"email": "E-mail",
"password": "Пароль",
"emailRequired": "E-mail обязателен",
"invalidEmail": "Введите корректный e-mail",
"passwordRequired": "Пароль обязателен",
"showPassword": "Показать пароль",
"hidePassword": "Скрыть пароль",
"signIn": "Войти",
"loginSuccess": "С возвращением!",
"invalidCredentials": "Неверный e-mail или пароль",
"tooManyAttempts": "Слишком много попыток — попробуйте позже.",
"networkError": "Сервер недоступен. Попробуйте ещё раз.",
"generalError": "Ошибка входа. Попробуйте ещё раз.",
"acceptedToast": "Аккаунт готов — войдите.",
"adminHint": "Ищете админ-панель? Перейдите на /admin/login.",
"emailPlaceholder": "[email protected]",
"passwordPlaceholder": "Your password",
"needHelp": "Need help?",
"poweredBy": "Powered by PicPeak"
},
"acceptInvite": {
"title": "Настройте ваш аккаунт",
"invalidToken": "Эта ссылка-приглашение недействительна или истекла. Свяжитесь с фотографом для нового приглашения.",
"emailWillBe": "E-mail вашего аккаунта будет ",
"invitedBy": ", пригласил ",
"name": "Ваше имя",
"nameRequired": "Введите ваше имя",
"password": "Выберите пароль",
"confirm": "Подтвердите пароль",
"passwordTooShort": "Пароль должен содержать не менее 8 символов",
"passwordsMismatch": "Пароли не совпадают",
"passwordHint": "Не менее 8 символов, с одной заглавной буквой и одной цифрой.",
"submit": "Создать аккаунт",
"successToast": "Аккаунт создан — войдите.",
"alreadyExists": "Аккаунт с таким e-mail уже существует. Войдите вместо этого.",
"invalidSubmission": "Не удалось создать аккаунт.",
"generalError": "Не удалось создать аккаунт. Попробуйте ещё раз.",
"subtitle": "Confirm or fill in your details. You can edit anything from the profile page later.",
"displayName": "Display name",
"section": {
"personal": "Personal",
"contact": "Contact & business (optional)",
"address": "Billing address (optional)",
"password": "Choose a password"
}
},
"dashboard": {
"title": "Your galleries",
"subtitle": "Click a gallery to open it. The Download button bundles every photo as a zip.",
"loadError": "Не удалось загрузить ваши галереи. Попробуйте ещё раз.",
"emptyTitle": "Пока нет галерей",
"emptyBody": "Как только фотограф привяжет вас к галерее, она появится здесь.",
"openAria": "Открыть галерею {{name}}",
"opening": "Открывается…",
"expiresOn": "Истекает {{date}}",
"expiredOn": "Истёк {{date}}",
"eventExpired": "Эта галерея истекла.",
"eventForbidden": "У вас больше нет доступа к этой галерее.",
"openError": "Не удалось открыть эту галерею. Попробуйте ещё раз.",
"download": "Скачать",
"preparingDownload": "Подготовка…",
"quickDownloadAria": "Скачать все фото для {{name}}",
"downloadStarted": "Скачивание начато для {{name}}",
"downloadError": "Не удалось начать скачивание. Попробуйте ещё раз.",
"sortLabel": "Sort by",
"sortNewest": "Newest first",
"sortOldest": "Oldest first",
"sortName": "By name",
"open": "Open"
},
"layout": {
"greeting": "Привет, {{name}}"
},
"nav": {
"galleries": "Galleries",
"calendar": "Calendar",
"quotes": "Quotes",
"bills": "Bills",
"profile": "Profile",
"soon": "Soon"
},
"comingSoon": {
"tag": "Coming soon"
},
"calendar": {
"title": "Calendar",
"body": "Upcoming sessions, gallery delivery dates, and other shoot-related events will land here. We'll let you know when it's ready."
},
"quotes": {
"title": "Quotes",
"body": "Review and accept quotes for upcoming shoots in one place. We're still building this — for now, your photographer will keep sending quotes the usual way."
},
"bills": {
"title": "Bills",
"body": "Invoices and payment history for your sessions will be available here. We'll send you an email when this is live."
},
"profile": {
"title": "Customer profile",
"subtitle": "Keep your contact and billing details up to date — they're shown on quotes and invoices once those features go live.",
"savedToast": "Profile saved",
"saveError": "Could not save profile.",
"loadError": "Could not load your profile.",
"save": "Save changes",
"salutation": {
"none": "— Not specified —",
"herr": "Mr.",
"frau": "Ms.",
"mx": "Mx",
"dr": "Dr."
},
"section": {
"personal": "Personal information",
"contact": "Contact & business",
"address": "Billing address",
"password": "Change password"
},
"field": {
"email": "Email (login)",
"emailHint": "Contact your photographer if you need to change your login email.",
"salutation": "Salutation",
"firstName": "First name",
"lastName": "Last name",
"displayName": "Display name",
"displayNameHint": "How we greet you in the dashboard.",
"phone": "Phone",
"companyName": "Company name",
"vatId": "VAT ID",
"addressLine1": "Address line 1",
"addressLine2": "Address line 2",
"postalCode": "Postal code",
"city": "City",
"state": "State / region",
"countryCode": "Country"
},
"password": {
"current": "Current password",
"next": "New password",
"confirm": "Confirm new password",
"submit": "Update password",
"hint": "At least 8 characters with one uppercase letter and one number.",
"currentRequired": "Enter your current password",
"tooShort": "At least 8 characters",
"mismatch": "Passwords do not match",
"wrong": "Current password is incorrect",
"savedToast": "Password updated",
"error": "Could not change password"
}
},
"resetPassword": {
"title": "Reset your password",
"invalidToken": "This reset link is invalid or has expired. Please ask your photographer to send a new one.",
"forEmail": "Setting a new password for ",
"password": "New password",
"confirm": "Confirm new password",
"submit": "Update password",
"hint": "At least 8 characters with one uppercase letter and one number.",
"tooShort": "Password must be at least 8 characters",
"mismatch": "Passwords do not match",
"successToast": "Password updated. Please log in.",
"invalidSubmission": "Could not update your password.",
"generalError": "Could not update your password. Please try again."
}
},
"customers": {
"pageTitle": "Клиенты",
"pageSubtitle": "Повторные клиентские аккаунты, которые могут входить на /customer/login.",
"unnamed": "Без имени",
"empty": "Пока нет клиентов. Нажмите «Пригласить клиента», чтобы добавить.",
"loadError": "Не удалось загрузить клиентов",
"loadInvitationsError": "Не удалось загрузить приглашения",
"tabs": {
"customers": "Клиенты",
"invitations": "Приглашения"
},
"search": {
"placeholder": "Поиск по e-mail, имени или компании"
},
"table": {
"name": "Имя",
"email": "E-mail",
"company": "Компания",
"eventCount": "События",
"lastLogin": "Последний вход",
"status": "Статус"
},
"status": {
"active": "Активен",
"inactive": "Отключён"
},
"invite": {
"button": "Пригласить клиента",
"title": "Пригласить клиента",
"description": "Они получат e-mail со ссылкой для настройки аккаунта. После принятия их можно назначать на события.",
"email": "E-mail",
"submit": "Отправить приглашение",
"success": "Приглашение отправлено",
"error": "Не удалось отправить приглашение.",
"conflict": "Клиент с таким e-mail уже существует или имеет ожидающее приглашение.",
"invalidEmail": "Введите корректный e-mail",
"showPrefill": "+ Add contact details (optional)",
"hidePrefill": " Hide contact details",
"prefillHint": "Anything you fill in will be pre-populated on the customer's sign-up page — they can still edit it."
},
"invitations": {
"empty": "Нет ожидающих приглашений.",
"email": "E-mail",
"invitedBy": "Пригласил",
"expiresAt": "Истекает",
"createdAt": "Создано",
"cancel": "Отмена"
},
"deactivate": {
"button": "Деактивировать",
"title": "Деактивировать клиента?",
"body": "Они больше не смогут войти. Вы можете пригласить их повторно позже.",
"success": "Клиент деактивирован",
"error": "Не удалось деактивировать клиента"
},
"cancelInvitation": {
"title": "Отменить приглашение?",
"body": "Ссылка-приглашение перестанет работать немедленно.",
"success": "Приглашение отменено",
"error": "Не удалось отменить приглашение"
},
"detail": {
"loadError": "Не удалось загрузить клиента",
"saved": "Клиент сохранён",
"saveError": "Не удалось сохранить изменения.",
"emailConflict": "Этот e-mail уже используется другим клиентом.",
"save": "Сохранить изменения",
"expires": "истекает",
"accountSection": "Аккаунт",
"personalSection": "Личная информация",
"billingSection": "Адрес и биллинг",
"notesSection": "Внутренние заметки",
"eventsSection": "Назначенные события",
"noEvents": "Пока не привязан ни к одному событию. Добавьте этого клиента в форме события.",
"email": "E-mail",
"preferredLanguage": "Предпочтительный язык",
"salutation": "Обращение",
"salutationNone": "—",
"firstName": "Имя",
"lastName": "Фамилия",
"displayName": "Отображаемое имя",
"phone": "Телефон",
"company": "Компания",
"billingEmail": "E-mail для счетов",
"vatId": "НДС / ИНН",
"addressLine1": "Адрес, строка 1",
"addressLine2": "Адрес, строка 2",
"postalCode": "Почтовый индекс",
"city": "Город",
"state": "Область / регион",
"countryCode": "Страна (ISO 2)",
"notesHint": "Видно только администраторам. Никогда не показывается клиенту.",
"featuresSection": "Customer features",
"featuresHint": "Per-customer overrides for the customer-surface tabs. The global toggles in Settings → Customer Surface are the master switch — when global is OFF nobody sees the tab, regardless of what you set here. Defaults are ON, so flip a switch OFF to hide a tab for this specific customer.",
"passwordSection": "Account actions",
"passwordHint": "Sends a 7-day single-use reset link to the customer's email. The customer's current password keeps working until they click the link and set a new one.",
"passwordReset": {
"button": "Send password reset email",
"success": "Password reset email sent",
"error": "Could not send password reset",
"inactive": "Reactivate the customer before sending a reset."
}
},
"reactivate": {
"button": "Reactivate",
"success": "Customer reactivated",
"error": "Could not reactivate customer"
},
"erase": {
"button": "Erase customer data",
"title": "Erase customer data?",
"body": "Removes the customer's name, email, phone, address, company and credentials. The account row stays so historical event-access records and audit logs still reference it. This is irreversible — you cannot restore the data afterwards.",
"confirm": "Erase permanently",
"confirmInFlight": "Erasing…",
"success": "Customer erased",
"error": "Could not erase customer"
}
}
}
+40
View File
@@ -416,6 +416,46 @@
@apply text-neutral-500;
}
/*
* Customer-surface scope (#354): the default .input and .card classes
* hard-code bg-white, and the customer surface uses theme variables
* rather than the admin's `.dark` class trigger. Overriding here so
* every <Input> and <Card> rendered inside the customer surface picks
* up var(--color-surface) / var(--color-text) automatically — no
* per-page wrapper needed. Same treatment for native <select>
* elements which share the .input mental model on the profile and
* accept-invite forms.
*
* The .customer-surface marker is set on the root <div> of every
* customer page (CustomerLayout, CustomerLoginPage, CustomerAcceptInvitePage,
* CustomerResetPasswordPage). Pages outside this scope (admin, public
* gallery) keep their original styling.
*/
.customer-surface .card,
.customer-surface .card-hover {
background-color: var(--color-surface);
border-color: var(--color-surface-border);
}
.customer-surface .input,
.customer-surface select.input,
.customer-surface input.input {
background-color: var(--color-surface);
border-color: var(--color-surface-border);
color: var(--color-text);
}
.customer-surface .input::placeholder {
color: var(--color-muted-text);
opacity: 0.7;
}
.customer-surface .input:disabled {
background-color: var(--color-elevated, var(--color-surface));
color: var(--color-muted-text);
opacity: 0.7;
}
.dark .input-themed {
@apply bg-neutral-800 border-neutral-700 text-neutral-100;
}
@@ -17,6 +17,7 @@ import { toast } from 'react-toastify';
import { Button, Input, Card, PasswordGenerator } from '../../components/common';
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor, FeedbackSettings } from '../../components/admin';
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
import { useMutation, useQuery } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
@@ -68,6 +69,10 @@ interface FormData {
client_password: string;
// Default photo sort
default_photo_sort: string;
// Customer accounts assigned to this event (#354). The state holds
// the full picker selection so chips render without an extra fetch;
// only the ids are sent to the backend on submit.
customer_accounts: Array<{ id: number; email: string; displayName: string | null }>;
}
// Fallback event types (used when API is unavailable)
@@ -127,6 +132,7 @@ export const CreateEventPage: React.FC = () => {
client_access_enabled: false,
client_password: '',
default_photo_sort: 'upload_date_desc',
customer_accounts: [],
});
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
@@ -437,6 +443,10 @@ export const CreateEventPage: React.FC = () => {
client_password: formData.client_access_enabled ? formData.client_password : undefined,
// Default photo sort
default_photo_sort: formData.default_photo_sort,
// Customer accounts assigned to this event (#354). Sent as a flat
// array of ids; the backend service diffs against the existing
// assignments and applies adds/removes inside one transaction.
customer_account_ids: formData.customer_accounts.map((c) => c.id),
};
createMutation.mutate(payload);
@@ -755,6 +765,15 @@ export const CreateEventPage: React.FC = () => {
/>
)}
{/* Customer accounts (#354). The picker is decoupled from
the freeform customer_name / customer_email fields above
— those stay as the event's primary contact while
customer_account_ids drives login-level access. */}
<CustomerAccountPicker
value={formData.customer_accounts}
onChange={(next) => setFormData((prev) => ({ ...prev, customer_accounts: next }))}
/>
<Input
type="email"
label={requireAdminEmail ? t('events.adminEmail') : `${t('events.adminEmail')} (${t('common.optional')})`}
@@ -0,0 +1,576 @@
/**
* Admin → Customer detail / edit (#354).
*
* Mounted at /admin/customers/:id. Editable view of every field on the
* customer_accounts table — name, salutation, address, billing, notes —
* so an admin can keep the record current for future quotes/invoicing
* features. Also lists the events the customer is currently assigned to
* (linked to the event detail page; assignments themselves are managed
* from the event form, not here).
*/
import React, { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import {
ArrowLeft, Mail, MapPin, Phone, Building2, Save, Trash2, AlertTriangle,
CheckCircle2, X, FileText, Calendar, KeyRound, ToggleLeft,
} from 'lucide-react';
import { format } from 'date-fns';
import { Button, Card, Input, Loading } from '../../components/common';
import {
customerAdminService,
type CustomerAccountDetail,
} from '../../services/customerAdmin.service';
type EditableFields =
| 'email' | 'salutation' | 'firstName' | 'lastName' | 'displayName'
| 'phone' | 'companyName' | 'billingEmail' | 'vatId'
| 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state'
| 'countryCode' | 'preferredLanguage' | 'notes'
| 'featureCalendar' | 'featureQuotes' | 'featureBills';
const formatDate = (iso: string | null | undefined) => {
if (!iso) return '—';
try { return format(new Date(iso), 'PP'); } catch { return '—'; }
};
export const CustomerDetailPage: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const customerId = Number(id);
const { data: customer, isLoading, error } = useQuery({
queryKey: ['admin-customer', customerId],
queryFn: () => customerAdminService.get(customerId),
enabled: Number.isFinite(customerId) && customerId > 0,
});
const [form, setForm] = useState<Partial<Pick<CustomerAccountDetail, EditableFields>>>({});
const [confirmDeactivate, setConfirmDeactivate] = useState(false);
const [confirmErase, setConfirmErase] = useState(false);
// Hydrate the form from the fetched record once. We deliberately do NOT
// re-sync on every refetch so an admin's in-progress edits aren't blown
// away by a background refresh.
useEffect(() => {
if (customer && Object.keys(form).length === 0) {
setForm({
email: customer.email,
salutation: customer.salutation,
firstName: customer.firstName,
lastName: customer.lastName,
displayName: customer.displayName,
phone: customer.phone,
companyName: customer.companyName,
billingEmail: customer.billingEmail,
vatId: customer.vatId,
addressLine1: customer.addressLine1,
addressLine2: customer.addressLine2,
postalCode: customer.postalCode,
city: customer.city,
state: customer.state,
countryCode: customer.countryCode,
preferredLanguage: customer.preferredLanguage,
notes: customer.notes,
featureCalendar: customer.featureCalendar ?? false,
featureQuotes: customer.featureQuotes ?? false,
featureBills: customer.featureBills ?? false,
} as any);
}
}, [customer, form]);
const toggleFeature = (key: 'featureCalendar' | 'featureQuotes' | 'featureBills') => {
setForm((prev) => ({ ...prev, [key]: !prev[key] }) as any);
};
const setField = (key: EditableFields) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
setForm((prev) => ({ ...prev, [key]: e.target.value }));
const saveMutation = useMutation({
mutationFn: () => customerAdminService.update(customerId, form),
onSuccess: (updated) => {
queryClient.setQueryData(['admin-customer', customerId], updated);
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
toast.success(t('customers.detail.saved', 'Customer saved'));
},
onError: (e: any) => {
const msg = e?.response?.status === 409
? t('customers.detail.emailConflict', 'That email is already in use by another customer.')
: e?.response?.data?.error || t('customers.detail.saveError', 'Could not save changes.');
toast.error(msg);
},
});
/**
* Trigger a password-reset email. Reused permission `customers.create`
* server-side because issuing a reset is the same authority level as
* issuing an invitation (both put a credential in the customer's mailbox).
* Confirm dialog ahead of the click is surfaced via the same modal
* pattern as deactivate.
*/
const passwordResetMutation = useMutation({
mutationFn: () => customerAdminService.sendPasswordReset(customerId),
onSuccess: () => toast.success(t('customers.detail.passwordReset.success', 'Password reset email sent')),
onError: () => toast.error(t('customers.detail.passwordReset.error', 'Could not send password reset')),
});
const deactivateMutation = useMutation({
mutationFn: () => customerAdminService.deactivate(customerId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
toast.success(t('customers.deactivate.success', 'Customer deactivated'));
navigate('/admin/customers');
},
onError: () => toast.error(t('customers.deactivate.error', 'Could not deactivate customer')),
});
/** Re-enable login for a deactivated customer. */
const reactivateMutation = useMutation({
mutationFn: () => customerAdminService.reactivate(customerId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
toast.success(t('customers.reactivate.success', 'Customer reactivated'));
},
onError: () => toast.error(t('customers.reactivate.error', 'Could not reactivate customer')),
});
/**
* Anonymize-in-place erasure. Two-step UX: requires the customer to be
* deactivated first, then a separate confirm modal. Hard delete is
* deliberately NOT exposed — see service notes for why.
*/
const eraseMutation = useMutation({
mutationFn: () => customerAdminService.erase(customerId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
toast.success(t('customers.erase.success', 'Customer erased'));
navigate('/admin/customers');
},
onError: () => toast.error(t('customers.erase.error', 'Could not erase customer')),
});
if (isLoading) {
return <div className="flex justify-center py-16"><Loading /></div>;
}
if (error || !customer) {
return (
<div className="container py-6">
<div className="text-sm text-red-600 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
{t('customers.detail.loadError', 'Could not load customer')}
</div>
</div>
);
}
return (
<div className="container py-6 space-y-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 min-w-0">
<Link
to="/admin/customers"
className="p-2 -ml-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
aria-label={t('common.back', 'Back')}
>
<ArrowLeft className="w-4 h-4 text-muted-theme" />
</Link>
<div className="min-w-0">
<h1 className="text-2xl font-bold text-theme truncate">
{customer.displayName || customer.email}
</h1>
<p className="text-sm text-muted-theme truncate">{customer.email}</p>
</div>
</div>
<div className="flex items-center gap-2">
{customer.isActive ? (
<span className="inline-flex items-center gap-1 text-xs" style={{ color: 'var(--color-accent)' }}>
<CheckCircle2 className="w-3.5 h-3.5" />
{t('customers.status.active', 'Active')}
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-red-600">
<X className="w-3.5 h-3.5" />
{t('customers.status.inactive', 'Deactivated')}
</span>
)}
</div>
</div>
{/* Account section */}
<Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
<Mail className="w-5 h-5" /> {t('customers.detail.accountSection', 'Account')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.email', 'Email')}</label>
<Input type="email" value={form.email || ''} onChange={setField('email')} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.preferredLanguage', 'Preferred language')}</label>
<select
value={form.preferredLanguage || 'en'}
onChange={setField('preferredLanguage')}
className="input"
>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="nl">Nederlands</option>
<option value="pt">Português</option>
<option value="ru">Русский</option>
</select>
</div>
</div>
</Card>
{/* Personal section */}
<Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-4">
{t('customers.detail.personalSection', 'Personal information')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.salutation', 'Salutation')}</label>
{/* Salutation values are stored verbatim in the DB ("Herr",
"Frau", "Mx", "Dr") — those are the canonical token values
across locales. Display labels are translated; the value
attribute stays in the German form so existing rows
remain valid regardless of which locale the admin is
viewing the dropdown in. */}
<select
value={form.salutation || ''}
onChange={setField('salutation')}
className="input"
>
<option value="">{t('customer.profile.salutation.none', '— Not specified —')}</option>
<option value="Herr">{t('customer.profile.salutation.herr', 'Mr.')}</option>
<option value="Frau">{t('customer.profile.salutation.frau', 'Ms.')}</option>
<option value="Mx">{t('customer.profile.salutation.mx', 'Mx')}</option>
<option value="Dr">{t('customer.profile.salutation.dr', 'Dr.')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.firstName', 'First name')}</label>
<Input value={form.firstName || ''} onChange={setField('firstName')} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.lastName', 'Last name')}</label>
<Input value={form.lastName || ''} onChange={setField('lastName')} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.displayName', 'Display name')}</label>
<Input value={form.displayName || ''} onChange={setField('displayName')} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1 flex items-center gap-1">
<Phone className="w-4 h-4" /> {t('customers.detail.phone', 'Phone')}
</label>
<Input value={form.phone || ''} onChange={setField('phone')} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1 flex items-center gap-1">
<Building2 className="w-4 h-4" /> {t('customers.detail.company', 'Company')}
</label>
<Input value={form.companyName || ''} onChange={setField('companyName')} />
</div>
</div>
</Card>
{/* Address + billing */}
<Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
<MapPin className="w-5 h-5" /> {t('customers.detail.billingSection', 'Address & billing')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.billingEmail', 'Billing email')}</label>
<Input type="email" value={form.billingEmail || ''} onChange={setField('billingEmail')} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.vatId', 'VAT / tax ID')}</label>
<Input value={form.vatId || ''} onChange={setField('vatId')} />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.addressLine1', 'Address line 1')}</label>
<Input value={form.addressLine1 || ''} onChange={setField('addressLine1')} />
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.addressLine2', 'Address line 2')}</label>
<Input value={form.addressLine2 || ''} onChange={setField('addressLine2')} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.postalCode', 'Postal code')}</label>
<Input value={form.postalCode || ''} onChange={setField('postalCode')} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.city', 'City')}</label>
<Input value={form.city || ''} onChange={setField('city')} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.state', 'State / region')}</label>
<Input value={form.state || ''} onChange={setField('state')} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.countryCode', 'Country (ISO 2)')}</label>
<Input
value={form.countryCode || ''}
onChange={setField('countryCode')}
maxLength={2}
placeholder="e.g. CH"
/>
</div>
</div>
</Card>
{/* Per-customer feature flags (#354 follow-up) */}
<Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
<ToggleLeft className="w-5 h-5" />
{t('customers.detail.featuresSection', 'Customer features')}
</h2>
<p className="text-xs text-muted-theme mb-4">
{t(
'customers.detail.featuresHint',
'Per-customer overrides for the customer-surface tabs. The global toggles in Settings → Features are the master switch — when global is OFF nobody sees the tab, regardless of what you set here. Defaults are ON, so flip a switch OFF to hide a tab for this specific customer.'
)}
</p>
<div className="space-y-3">
{([
{ key: 'featureCalendar', labelKey: 'customer.nav.calendar', fallback: 'Calendar' },
{ key: 'featureQuotes', labelKey: 'customer.nav.quotes', fallback: 'Quotes' },
{ key: 'featureBills', labelKey: 'customer.nav.bills', fallback: 'Bills' },
] as const).map(({ key, labelKey, fallback }) => {
const enabled = !!form[key];
return (
<label key={key} className="flex items-center justify-between gap-3 cursor-pointer">
<span className="text-sm font-medium text-theme flex items-center gap-2">
{t(labelKey, fallback)}
{/* Soon badge — these tabs are still coming-soon stubs;
this keeps the admin honest when looking at the
toggles. */}
<span
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"
>
{t('customer.nav.soon', 'Soon')}
</span>
</span>
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={() => toggleFeature(key)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{ backgroundColor: enabled ? 'var(--color-accent)' : 'var(--color-surface-border)' }}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`}
/>
</button>
</label>
);
})}
</div>
</Card>
{/* Account actions: password reset (#354 follow-up) */}
<Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
<KeyRound className="w-5 h-5" />
{t('customers.detail.passwordSection', 'Account actions')}
</h2>
<p className="text-xs text-muted-theme mb-4">
{t(
'customers.detail.passwordHint',
'Sends a 7-day single-use reset link to the customer\'s email. The customer\'s current password keeps working until they click the link and set a new one.'
)}
</p>
<Button
variant="outline"
leftIcon={<KeyRound className="w-4 h-4" />}
isLoading={passwordResetMutation.isPending}
disabled={!customer.isActive}
onClick={() => passwordResetMutation.mutate()}
>
{t('customers.detail.passwordReset.button', 'Send password reset email')}
</Button>
{!customer.isActive && (
<p className="text-xs text-muted-theme mt-2">
{t('customers.detail.passwordReset.inactive', 'Reactivate the customer before sending a reset.')}
</p>
)}
</Card>
{/* Notes (admin-only) */}
<Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
<FileText className="w-5 h-5" /> {t('customers.detail.notesSection', 'Internal notes')}
</h2>
<p className="text-xs text-muted-theme mb-3">
{t('customers.detail.notesHint', 'Visible only to admins. Never shown to the customer.')}
</p>
<textarea
value={form.notes || ''}
onChange={setField('notes') as any}
rows={4}
className="input w-full"
/>
</Card>
{/* Assigned events */}
<Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
<Calendar className="w-5 h-5" /> {t('customers.detail.eventsSection', 'Assigned events')}
</h2>
{customer.events.length === 0 ? (
<p className="text-sm text-muted-theme">
{t('customers.detail.noEvents', 'Not assigned to any events yet. Add this customer to an event from the event form.')}
</p>
) : (
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
{customer.events.map((ev) => (
<li key={ev.id} className="py-2 flex items-center justify-between">
<Link to={`/admin/events/${ev.id}`} className="text-theme hover:underline">
{ev.eventName}
</Link>
<span className="text-xs text-muted-theme">
{ev.eventDate ? formatDate(ev.eventDate) : ''}
{ev.expiresAt ? ` · ${t('customers.detail.expires', 'expires')} ${formatDate(ev.expiresAt)}` : ''}
</span>
</li>
))}
</ul>
)}
</Card>
{/* Actions */}
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
{customer.isActive ? (
<Button
variant="outline"
leftIcon={<Trash2 className="w-4 h-4" />}
onClick={() => setConfirmDeactivate(true)}
>
{t('customers.deactivate.button', 'Deactivate')}
</Button>
) : (
<>
<Button
variant="outline"
leftIcon={<CheckCircle2 className="w-4 h-4" />}
isLoading={reactivateMutation.isPending}
onClick={() => reactivateMutation.mutate()}
>
{t('customers.reactivate.button', 'Reactivate')}
</Button>
{/* Erase is only offered when the customer is already
inactive — forces a deliberate two-step (deactivate
→ erase) and removes the chance of misclicking through
the deactivate button on a live account. */}
<Button
variant="outline"
leftIcon={<Trash2 className="w-4 h-4 text-red-600" />}
onClick={() => setConfirmErase(true)}
>
<span className="text-red-600">
{t('customers.erase.button', 'Erase customer data')}
</span>
</Button>
</>
)}
</div>
<Button
variant="primary"
leftIcon={<Save className="w-4 h-4" />}
isLoading={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
{t('customers.detail.save', 'Save changes')}
</Button>
</div>
{confirmDeactivate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div className="w-full max-w-md rounded-xl shadow-lg" style={{ backgroundColor: 'var(--color-surface)' }}>
<div className="p-6">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="w-5 h-5 mt-0.5 text-amber-500" />
<div>
<h2 className="text-lg font-semibold text-theme">
{t('customers.deactivate.title', 'Deactivate customer?')}
</h2>
<p className="mt-1 text-sm text-muted-theme">
{t('customers.deactivate.body',
'They will no longer be able to log in. You can re-activate or fully erase them later.')}
</p>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setConfirmDeactivate(false)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
isLoading={deactivateMutation.isPending}
onClick={() => { deactivateMutation.mutate(); setConfirmDeactivate(false); }}
>
{t('common.confirm', 'Confirm')}
</Button>
</div>
</div>
</div>
</div>
)}
{/* Erase confirm modal — second step after deactivate. Spelled out
"irreversible" copy + red Confirm button so the click feels
deliberate. The action anonymizes PII in place; assignments
and audit-log references are preserved. */}
{confirmErase && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div className="w-full max-w-md rounded-xl shadow-lg" style={{ backgroundColor: 'var(--color-surface)' }}>
<div className="p-6">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="w-5 h-5 mt-0.5 text-red-600" />
<div>
<h2 className="text-lg font-semibold text-theme">
{t('customers.erase.title', 'Erase customer data?')}
</h2>
<p className="mt-1 text-sm text-muted-theme">
{t('customers.erase.body',
'Removes the customer\'s name, email, phone, address, company and credentials. The account row stays so historical event-access records and audit logs still reference it. This is irreversible — you cannot restore the data afterwards.')}
</p>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setConfirmErase(false)}>
{t('common.cancel', 'Cancel')}
</Button>
<button
type="button"
className="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-60 disabled:cursor-not-allowed"
disabled={eraseMutation.isPending}
onClick={() => { eraseMutation.mutate(); setConfirmErase(false); }}
>
{eraseMutation.isPending
? t('customers.erase.confirmInFlight', 'Erasing…')
: t('customers.erase.confirm', 'Erase permanently')}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default CustomerDetailPage;
@@ -0,0 +1,597 @@
/**
* Admin → Customer accounts management (#354).
*
* Mounted at /admin/customers. Listed in AdminSidebar gated on
* `customers.view` so only super_admin / admin see it.
*
* NOT a duplicate of UserManagementPage:
* - admin_users table (admin RBAC, token type 'admin', /admin/login)
* - customer_accounts table (per-event access, token type 'customer', /customer/login)
*
* The two pages share visual patterns (tabbed list + invite modal) but
* operate on completely different DB tables, services, auth surfaces,
* and permission models. The customer invite intentionally has no role
* picker (customers don't have roles — access is boolean per event,
* managed via the event form's CustomerAccountPicker).
*/
import React, { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import {
UserPlus, Mail, Trash2, Search, X, AlertTriangle, CheckCircle2, Clock,
} from 'lucide-react';
import { format } from 'date-fns';
import { Button, Card, Input, Loading } from '../../components/common';
import {
customerAdminService,
type CustomerAccountSummary,
type CustomerInvitationSummary,
} from '../../services/customerAdmin.service';
type TabType = 'customers' | 'invitations';
const formatDate = (iso: string | null | undefined) => {
if (!iso) return '—';
try { return format(new Date(iso), 'PP'); } catch { return '—'; }
};
/**
* Invite modal with optional prefill (#354 follow-up).
*
* Email is the only required field. Everything else is collected behind
* a "Add contact details" toggle so a fast invite stays one-click. When
* filled, the values are stashed on the invitation row and re-rendered
* pre-populated on the customer's accept page (where the customer can
* still edit before submitting).
*/
const InviteModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onInvited: () => void;
}> = ({ isOpen, onClose, onInvited }) => {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [showPrefill, setShowPrefill] = useState(false);
const [prefill, setPrefill] = useState({
salutation: '', first_name: '', last_name: '', display_name: '',
phone: '', company_name: '', vat_id: '',
address_line1: '', address_line2: '', postal_code: '', city: '', state: '', country_code: '',
});
const updatePrefill = (key: keyof typeof prefill, value: string) => {
setPrefill((p) => ({ ...p, [key]: value }));
};
const reset = () => {
setEmail('');
setError(null);
setShowPrefill(false);
setPrefill({
salutation: '', first_name: '', last_name: '', display_name: '',
phone: '', company_name: '', vat_id: '',
address_line1: '', address_line2: '', postal_code: '', city: '', state: '', country_code: '',
});
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError(t('customers.invite.invalidEmail', 'Please enter a valid email'));
return;
}
setSubmitting(true);
try {
// Strip empty values so the backend stores `null`/nothing for fields
// the admin didn't actually fill in. Saves a round trip through
// the backend's sanitiser and keeps the JSON payload small.
const cleaned: Record<string, string> = {};
for (const [k, v] of Object.entries(prefill)) {
const trimmed = v.trim();
if (trimmed) cleaned[k] = trimmed;
}
await customerAdminService.invite(
email.trim(),
Object.keys(cleaned).length > 0 ? cleaned : undefined,
);
toast.success(t('customers.invite.success', 'Invitation sent'));
reset();
onInvited();
onClose();
} catch (e: any) {
const msg = e?.response?.status === 409
? t('customers.invite.conflict', 'A customer with this email already exists or has a pending invitation.')
: e?.response?.data?.error || t('customers.invite.error', 'Could not send invitation.');
setError(msg);
} finally {
setSubmitting(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8 overflow-y-auto">
<div className="w-full max-w-2xl rounded-xl shadow-lg my-auto" style={{ backgroundColor: 'var(--color-surface)' }}>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-theme">
{t('customers.invite.title', 'Invite a customer')}
</h2>
<button
type="button"
onClick={() => { reset(); onClose(); }}
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5 text-muted-theme" />
</button>
</div>
<p className="text-sm text-muted-theme mb-4">
{t('customers.invite.description',
'They\'ll receive an email with a link to set up their account. Once they\'ve accepted, you can assign them to events.')}
</p>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customers.invite.email', 'Email')} <span className="text-red-500">*</span>
</label>
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
error={error || undefined}
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
autoFocus
/>
</div>
<div className="border-t pt-4" style={{ borderColor: 'var(--color-surface-border)' }}>
<button
type="button"
onClick={() => setShowPrefill((v) => !v)}
className="text-sm font-medium hover:underline"
style={{ color: 'var(--color-accent)' }}
>
{showPrefill
? t('customers.invite.hidePrefill', ' Hide contact details')
: t('customers.invite.showPrefill', '+ Add contact details (optional)')}
</button>
<p className="mt-1 text-xs text-muted-theme">
{t('customers.invite.prefillHint',
'Anything you fill in will be pre-populated on the customer\'s sign-up page — they can still edit it.')}
</p>
</div>
{showPrefill && (
<div className="space-y-4 pt-2">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.salutation', 'Salutation')}
</label>
<select
value={prefill.salutation}
onChange={(e) => updatePrefill('salutation', e.target.value)}
className="w-full rounded-lg border px-3 h-10 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
color: 'var(--color-text)',
}}
>
<option value="">{t('customer.profile.salutation.none', '— Not specified —')}</option>
<option value="Herr">{t('customer.profile.salutation.herr', 'Mr.')}</option>
<option value="Frau">{t('customer.profile.salutation.frau', 'Ms.')}</option>
<option value="Mx">{t('customer.profile.salutation.mx', 'Mx')}</option>
<option value="Dr">{t('customer.profile.salutation.dr', 'Dr.')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.displayName', 'Display name')}
</label>
<Input value={prefill.display_name} onChange={(e) => updatePrefill('display_name', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.firstName', 'First name')}
</label>
<Input value={prefill.first_name} onChange={(e) => updatePrefill('first_name', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.lastName', 'Last name')}
</label>
<Input value={prefill.last_name} onChange={(e) => updatePrefill('last_name', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.phone', 'Phone')}
</label>
<Input value={prefill.phone} onChange={(e) => updatePrefill('phone', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.companyName', 'Company name')}
</label>
<Input value={prefill.company_name} onChange={(e) => updatePrefill('company_name', e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.vatId', 'VAT ID')}
</label>
<Input value={prefill.vat_id} onChange={(e) => updatePrefill('vat_id', e.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-6 gap-3">
<div className="sm:col-span-6">
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.addressLine1', 'Address line 1')}
</label>
<Input value={prefill.address_line1} onChange={(e) => updatePrefill('address_line1', e.target.value)} />
</div>
<div className="sm:col-span-6">
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.addressLine2', 'Address line 2')}
</label>
<Input value={prefill.address_line2} onChange={(e) => updatePrefill('address_line2', e.target.value)} />
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.postalCode', 'Postal code')}
</label>
<Input value={prefill.postal_code} onChange={(e) => updatePrefill('postal_code', e.target.value)} />
</div>
<div className="sm:col-span-3">
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.city', 'City')}
</label>
<Input value={prefill.city} onChange={(e) => updatePrefill('city', e.target.value)} />
</div>
<div className="sm:col-span-1">
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.countryCode', 'Country')}
</label>
<Input
value={prefill.country_code}
onChange={(e) => updatePrefill('country_code', e.target.value.toUpperCase().slice(0, 2))}
placeholder="DE"
maxLength={2}
/>
</div>
<div className="sm:col-span-3">
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.state', 'State / region')}
</label>
<Input value={prefill.state} onChange={(e) => updatePrefill('state', e.target.value)} />
</div>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => { reset(); onClose(); }}>
{t('common.cancel', 'Cancel')}
</Button>
<Button type="submit" variant="primary" isLoading={submitting} leftIcon={<UserPlus className="w-4 h-4" />}>
{t('customers.invite.submit', 'Send invitation')}
</Button>
</div>
</form>
</div>
</div>
</div>
);
};
export const CustomerManagementPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<TabType>('customers');
const [searchTerm, setSearchTerm] = useState('');
const [inviteOpen, setInviteOpen] = useState(false);
const [confirm, setConfirm] = useState<{ kind: 'deactivate'; id: number; name: string } | { kind: 'cancelInvite'; id: number; email: string } | null>(null);
const { data: customers, isLoading: customersLoading, error: customersError } = useQuery({
queryKey: ['admin-customers'],
queryFn: () => customerAdminService.list(),
});
const { data: invitations, isLoading: invitationsLoading, error: invitationsError } = useQuery({
queryKey: ['admin-customer-invitations'],
queryFn: () => customerAdminService.listInvitations(),
});
const filteredCustomers = useMemo(() => {
const list = customers || [];
if (!searchTerm.trim()) return list;
const term = searchTerm.trim().toLowerCase();
return list.filter((c) =>
c.email.toLowerCase().includes(term)
|| (c.displayName || '').toLowerCase().includes(term)
|| (c.lastName || '').toLowerCase().includes(term)
|| (c.companyName || '').toLowerCase().includes(term)
);
}, [customers, searchTerm]);
const filteredInvitations = useMemo(() => {
const list = invitations || [];
if (!searchTerm.trim()) return list;
const term = searchTerm.trim().toLowerCase();
return list.filter((i) => i.email.toLowerCase().includes(term));
}, [invitations, searchTerm]);
const deactivateMutation = useMutation({
mutationFn: (id: number) => customerAdminService.deactivate(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
toast.success(t('customers.deactivate.success', 'Customer deactivated'));
},
onError: () => toast.error(t('customers.deactivate.error', 'Could not deactivate customer')),
});
const cancelInviteMutation = useMutation({
mutationFn: (id: number) => customerAdminService.cancelInvitation(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-customer-invitations'] });
toast.success(t('customers.cancelInvitation.success', 'Invitation cancelled'));
},
onError: () => toast.error(t('customers.cancelInvitation.error', 'Could not cancel invitation')),
});
const renderCustomerName = (c: CustomerAccountSummary) => {
const display = c.displayName?.trim()
|| [c.firstName, c.lastName].filter(Boolean).join(' ').trim()
|| c.companyName?.trim();
return display || <span className="text-muted-theme italic">{t('customers.unnamed', 'Unnamed')}</span>;
};
const renderTabs = () => (
<div className="flex gap-6 border-b mb-6" style={{ borderColor: 'var(--color-surface-border)' }}>
<button
type="button"
onClick={() => setActiveTab('customers')}
className={`pb-3 -mb-px border-b-2 text-sm font-medium ${
activeTab === 'customers' ? 'border-accent text-accent' : 'border-transparent text-muted-theme hover:text-theme'
}`}
>
{t('customers.tabs.customers', 'Customers')}
{customers ? <span className="ml-2 text-xs">({customers.length})</span> : null}
</button>
<button
type="button"
onClick={() => setActiveTab('invitations')}
className={`pb-3 -mb-px border-b-2 text-sm font-medium ${
activeTab === 'invitations' ? 'border-accent text-accent' : 'border-transparent text-muted-theme hover:text-theme'
}`}
>
{t('customers.tabs.invitations', 'Invitations')}
{invitations ? <span className="ml-2 text-xs">({invitations.length})</span> : null}
</button>
</div>
);
return (
<div className="container py-6">
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold text-theme">{t('customers.pageTitle', 'Customers')}</h1>
{/* Beta badge — Calendar/Quotes/Bills tabs in the customer
surface are placeholders, so flag the whole feature as
still evolving. Keeps expectations honest. */}
<span
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"
title="Beta — feature is functional but still evolving"
>
{t('navigation.betaTag', 'Beta')}
</span>
</div>
<p className="text-sm text-muted-theme mt-1">
{t('customers.pageSubtitle', 'Recurring customer accounts that can log in at /customer/login.')}
</p>
</div>
<Button variant="primary" leftIcon={<UserPlus className="w-4 h-4" />} onClick={() => setInviteOpen(true)}>
{t('customers.invite.button', 'Invite customer')}
</Button>
</div>
<Card padding="lg">
{renderTabs()}
<div className="mb-4">
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('customers.search.placeholder', 'Search by email, name, or company')}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
/>
</div>
{activeTab === 'customers' ? (
customersLoading ? (
<div className="flex justify-center py-8"><Loading /></div>
) : customersError ? (
<div className="text-sm text-red-600 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
{t('customers.loadError', 'Could not load customers')}
</div>
) : filteredCustomers.length === 0 ? (
<div className="text-center text-muted-theme py-12">
{t('customers.empty', 'No customers yet. Click "Invite customer" to add one.')}
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-muted-theme">
<th className="px-3 py-2 font-medium">{t('customers.table.name', 'Name')}</th>
<th className="px-3 py-2 font-medium">{t('customers.table.email', 'Email')}</th>
<th className="px-3 py-2 font-medium">{t('customers.table.company', 'Company')}</th>
<th className="px-3 py-2 font-medium">{t('customers.table.eventCount', 'Events')}</th>
<th className="px-3 py-2 font-medium">{t('customers.table.lastLogin', 'Last login')}</th>
<th className="px-3 py-2 font-medium">{t('customers.table.status', 'Status')}</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{filteredCustomers.map((c) => (
<tr key={c.id} className="border-t" style={{ borderColor: 'var(--color-surface-border)' }}>
<td className="px-3 py-3">
<Link to={`/admin/customers/${c.id}`} className="text-theme hover:underline">
{renderCustomerName(c)}
</Link>
</td>
<td className="px-3 py-3 text-muted-theme">{c.email}</td>
<td className="px-3 py-3 text-muted-theme">{c.companyName || '—'}</td>
<td className="px-3 py-3 text-muted-theme">{c.eventCount ?? 0}</td>
<td className="px-3 py-3 text-muted-theme">{formatDate(c.lastLogin)}</td>
<td className="px-3 py-3">
{c.isActive ? (
<span className="inline-flex items-center gap-1 text-xs" style={{ color: 'var(--color-accent)' }}>
<CheckCircle2 className="w-3.5 h-3.5" />
{t('customers.status.active', 'Active')}
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs text-red-600">
<X className="w-3.5 h-3.5" />
{t('customers.status.inactive', 'Deactivated')}
</span>
)}
</td>
<td className="px-3 py-3 text-right">
{c.isActive && (
<Button
type="button"
variant="outline"
size="sm"
leftIcon={<Trash2 className="w-4 h-4" />}
onClick={() => setConfirm({ kind: 'deactivate', id: c.id, name: c.email })}
>
{t('customers.deactivate.button', 'Deactivate')}
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
) : (
invitationsLoading ? (
<div className="flex justify-center py-8"><Loading /></div>
) : invitationsError ? (
<div className="text-sm text-red-600 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
{t('customers.loadInvitationsError', 'Could not load invitations')}
</div>
) : filteredInvitations.length === 0 ? (
<div className="text-center text-muted-theme py-12">
{t('customers.invitations.empty', 'No pending invitations.')}
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-muted-theme">
<th className="px-3 py-2 font-medium">{t('customers.invitations.email', 'Email')}</th>
<th className="px-3 py-2 font-medium">{t('customers.invitations.invitedBy', 'Invited by')}</th>
<th className="px-3 py-2 font-medium">{t('customers.invitations.expiresAt', 'Expires')}</th>
<th className="px-3 py-2 font-medium">{t('customers.invitations.createdAt', 'Created')}</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody>
{filteredInvitations.map((inv: CustomerInvitationSummary) => (
<tr key={inv.id} className="border-t" style={{ borderColor: 'var(--color-surface-border)' }}>
<td className="px-3 py-3 text-theme">{inv.email}</td>
<td className="px-3 py-3 text-muted-theme">{inv.invitedBy || '—'}</td>
<td className="px-3 py-3 text-muted-theme">
<span className="inline-flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{formatDate(inv.expiresAt)}
</span>
</td>
<td className="px-3 py-3 text-muted-theme">{formatDate(inv.createdAt)}</td>
<td className="px-3 py-3 text-right">
<Button
type="button"
variant="outline"
size="sm"
leftIcon={<X className="w-4 h-4" />}
onClick={() => setConfirm({ kind: 'cancelInvite', id: inv.id, email: inv.email })}
>
{t('customers.invitations.cancel', 'Cancel')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
)}
</Card>
<InviteModal
isOpen={inviteOpen}
onClose={() => setInviteOpen(false)}
onInvited={() => queryClient.invalidateQueries({ queryKey: ['admin-customer-invitations'] })}
/>
{confirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div className="w-full max-w-md rounded-xl shadow-lg" style={{ backgroundColor: 'var(--color-surface)' }}>
<div className="p-6">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="w-5 h-5 mt-0.5 text-amber-500" />
<div>
<h2 className="text-lg font-semibold text-theme">
{confirm.kind === 'deactivate'
? t('customers.deactivate.title', 'Deactivate customer?')
: t('customers.cancelInvitation.title', 'Cancel invitation?')}
</h2>
<p className="mt-1 text-sm text-muted-theme">
{confirm.kind === 'deactivate'
? t('customers.deactivate.body',
'They will no longer be able to log in. You can re-invite them later.')
: t('customers.cancelInvitation.body',
'The invitation link will stop working immediately.')}
</p>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setConfirm(null)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={() => {
if (confirm.kind === 'deactivate') {
deactivateMutation.mutate(confirm.id);
} else {
cancelInviteMutation.mutate(confirm.id);
}
setConfirm(null);
}}
isLoading={deactivateMutation.isPending || cancelInviteMutation.isPending}
>
{t('common.confirm', 'Confirm')}
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default CustomerManagementPage;
@@ -59,6 +59,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, Loading, MarkdownContent } from '../../components/common';
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
@@ -290,6 +291,10 @@ export const EventDetailsPage: React.FC = () => {
// off → no promo for this event regardless of global
promo_mode: 'inherit' | 'custom' | 'off';
promo_markdown: string;
// Customer accounts assigned to this event (#354). Hydrated from
// the GET /admin/events/:id response and sent back as a flat id
// array on save.
customer_accounts: Array<{ id: number; email: string; displayName: string | null }>;
};
const [isEditing, setIsEditing] = useState(false);
@@ -330,6 +335,8 @@ export const EventDetailsPage: React.FC = () => {
// Per-event promotional override (#440)
promo_mode: 'inherit',
promo_markdown: '',
// Customer accounts (#354) — hydrated from event response.
customer_accounts: [],
});
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
feedback_enabled: false,
@@ -588,6 +595,11 @@ export const EventDetailsPage: React.FC = () => {
// Per-event promotional override (#440)
promo_mode: ((event as { promo_mode?: 'inherit' | 'custom' | 'off' }).promo_mode) || 'inherit',
promo_markdown: (event as { promo_markdown?: string }).promo_markdown || '',
// Customer accounts (#354). The backend returns
// `customer_accounts: [{ id, email, display_name, ... }]`; map to
// the picker's shape.
customer_accounts: ((event as { customer_accounts?: Array<{ id: number; email: string; display_name?: string | null }> }).customer_accounts || [])
.map((c) => ({ id: c.id, email: c.email, displayName: c.display_name ?? null })),
});
setShowNewPassword(false);
@@ -734,6 +746,9 @@ export const EventDetailsPage: React.FC = () => {
// promo_markdown automatically when mode != 'custom'.
promo_mode: editForm.promo_mode,
promo_markdown: editForm.promo_mode === 'custom' ? editForm.promo_markdown : null,
// Customer accounts (#354) — flat array of ids. Backend diffs
// against existing assignments in one transaction.
customer_account_ids: editForm.customer_accounts.map((c) => c.id),
};
// Only include fields that have defined values
@@ -1131,6 +1146,13 @@ export const EventDetailsPage: React.FC = () => {
</div>
)}
{/* Customer accounts (#354). Picker self-hides when the
customerPortal feature flag is off. */}
<CustomerAccountPicker
value={editForm.customer_accounts}
onChange={(next) => setEditForm((prev) => ({ ...prev, customer_accounts: next }))}
/>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.expirationDate')}
+2
View File
@@ -12,5 +12,7 @@ export { CMSPage } from './CMSPage';
export { BackupManagement } from './BackupManagement';
export { EventFeedbackPage } from './EventFeedbackPage';
export { UserManagementPage } from './UserManagementPage';
export { CustomerManagementPage } from './CustomerManagementPage';
export { CustomerDetailPage } from './CustomerDetailPage';
export { EventTypesPage } from './EventTypesPage';
export { WebhookDeliveriesPage } from './WebhookDeliveriesPage';
@@ -0,0 +1,501 @@
/**
* Customer accept-invite page (#354).
*
* Mounted at /customer/invite/:token. Public route — anyone with the link
* can complete the invitation. The token IS the auth: 256 bits of entropy,
* single-use, 7-day TTL, server-side validated.
*
* Now collects the full profile (#354 follow-up):
* - admin can pre-fill any subset on /admin/customers invite, those
* values appear pre-populated and editable here
* - customer can correct or fill in anything else (phone, billing
* address, company)
* - password is the only required field besides the display name
*
* The profile fields are optional — a customer who just wants to log in
* fast can leave them blank and edit later from /customer/profile.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Lock, MapPin, Phone, User as UserIcon, AlertCircle, CheckCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card, Loading } from '../../components/common';
import {
customerService,
type CustomerInvitationInfo,
type CustomerProfilePrefill,
} from '../../services/customer.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
interface FormState {
display_name: string;
password: string;
confirm: string;
salutation: string;
first_name: string;
last_name: string;
phone: string;
company_name: string;
vat_id: string;
address_line1: string;
address_line2: string;
postal_code: string;
city: string;
state: string;
country_code: string;
}
const SALUTATION_OPTIONS = [
{ value: '', labelKey: 'customer.profile.salutation.none', fallback: '— Not specified —' },
{ value: 'Herr', labelKey: 'customer.profile.salutation.herr', fallback: 'Herr' },
{ value: 'Frau', labelKey: 'customer.profile.salutation.frau', fallback: 'Frau' },
{ value: 'Mx', labelKey: 'customer.profile.salutation.mx', fallback: 'Mx' },
{ value: 'Dr', labelKey: 'customer.profile.salutation.dr', fallback: 'Dr.' },
];
const EMPTY: FormState = {
display_name: '', password: '', confirm: '',
salutation: '', first_name: '', last_name: '',
phone: '', company_name: '', vat_id: '',
address_line1: '', address_line2: '', postal_code: '', city: '', state: '', country_code: '',
};
export const CustomerAcceptInvitePage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { token = '' } = useParams<{ token: string }>();
const [invitation, setInvitation] = useState<CustomerInvitationInfo | null>(null);
const [lookupError, setLookupError] = useState<string | null>(null);
const [isLookingUp, setIsLookingUp] = useState(true);
const [form, setForm] = useState<FormState>(EMPTY);
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: settingsData } = usePublicSettings();
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = settingsData?.branding_logo_url?.trim();
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
// Pre-flight invitation lookup. The response carries any prefill data
// the admin attached on /admin/customers invite — populate the form
// with that so the customer doesn't retype what their photographer
// already knows.
useEffect(() => {
let cancelled = false;
setIsLookingUp(true);
customerService.getInvitation(token)
.then((info) => {
if (cancelled) return;
setInvitation(info);
if (info.prefill) {
setForm((prev) => ({ ...prev, ...mergePrefillIntoForm(prev, info.prefill!) }));
}
})
.catch(() => {
if (cancelled) return;
setLookupError(t(
'customer.acceptInvite.invalidToken',
'This invitation link is invalid or has expired. Please contact your photographer for a new invitation.'
));
})
.finally(() => {
if (!cancelled) setIsLookingUp(false);
});
return () => { cancelled = true; };
}, [token, t]);
/**
* The display_name shown in the form is admin-prefilled if available,
* otherwise constructed from first/last name so the customer sees
* something sensible without us silently changing what they type.
*/
const initialDisplayName = useMemo(() => {
if (form.display_name) return form.display_name;
const fromParts = [form.first_name, form.last_name].filter(Boolean).join(' ').trim();
return fromParts || '';
}, [form.display_name, form.first_name, form.last_name]);
const update = (key: keyof FormState, value: string) => {
setForm((p) => ({ ...p, [key]: value }));
};
const validate = (): boolean => {
const next: Record<string, string> = {};
if (!initialDisplayName.trim()) {
next.display_name = t('customer.acceptInvite.nameRequired', 'Please enter your name');
}
if (form.password.length < 8) {
next.password = t('customer.acceptInvite.passwordTooShort', 'Password must be at least 8 characters');
}
if (form.password !== form.confirm) {
next.confirm = t('customer.acceptInvite.passwordsMismatch', 'Passwords do not match');
}
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setIsSubmitting(true);
try {
const profile: CustomerProfilePrefill = {
salutation: form.salutation || undefined,
first_name: form.first_name || undefined,
last_name: form.last_name || undefined,
display_name: form.display_name || undefined,
phone: form.phone || undefined,
company_name: form.company_name || undefined,
vat_id: form.vat_id || undefined,
address_line1: form.address_line1 || undefined,
address_line2: form.address_line2 || undefined,
postal_code: form.postal_code || undefined,
city: form.city || undefined,
state: form.state || undefined,
country_code: form.country_code || undefined,
};
await customerService.acceptInvitation(token, initialDisplayName.trim(), form.password, profile);
toast.success(t('customer.acceptInvite.successToast', 'Account created — please log in.'));
navigate('/customer/login?accepted=1', { replace: true });
} catch (error: any) {
if (error.response?.status === 409) {
setErrors({ form: t('customer.acceptInvite.alreadyExists', 'An account with this email already exists. Please log in instead.') });
} else if (error.response?.data?.details?.length) {
setErrors({ password: error.response.data.details.join(' ') });
} else if (error.response?.status === 400) {
setErrors({ form: error.response?.data?.error || t('customer.acceptInvite.invalidSubmission', 'Could not create your account.') });
} else {
toast.error(t('customer.acceptInvite.generalError', 'Could not create your account. Please try again.'));
}
} finally {
setIsSubmitting(false);
}
};
return (
<div
className="customer-surface min-h-screen flex items-center justify-center px-4 py-8"
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
>
<div className="w-full max-w-2xl">
<div className="text-center mb-8">
<img
src={resolvedLogoUrl}
alt={companyName}
className="h-16 w-auto object-contain mx-auto mb-4"
/>
<h1 className="text-2xl font-bold text-theme">
{t('customer.acceptInvite.title', 'Set up your account')}
</h1>
<p className="mt-2 text-sm text-muted-theme">
{t('customer.acceptInvite.subtitle', 'Confirm or fill in your details. You can edit anything from the profile page later.')}
</p>
</div>
<Card padding="lg">
{isLookingUp ? (
<div className="flex justify-center py-8"><Loading size="lg" /></div>
) : lookupError || !invitation ? (
<div className="flex items-start gap-2 text-sm">
<AlertCircle className="w-5 h-5 mt-0.5 flex-shrink-0 text-red-600" />
<p className="text-theme">{lookupError}</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="flex items-start gap-2 p-3 rounded-lg" style={{ backgroundColor: 'var(--color-elevated, #f5f5f5)' }}>
<CheckCircle className="w-5 h-5 mt-0.5 flex-shrink-0" style={{ color: 'var(--color-accent)' }} />
<div className="text-sm text-theme">
{t('customer.acceptInvite.emailWillBe', 'Your account email will be ')}
<span className="font-medium">{invitation.email}</span>
{invitation.invitedBy ? (
<>
{t('customer.acceptInvite.invitedBy', ', invited by ')}
<span className="font-medium">{invitation.invitedBy}</span>
</>
) : null}
.
</div>
</div>
{errors.form && (
<div role="alert" className="flex items-start gap-2 p-3 rounded-lg border" style={{ borderColor: 'var(--color-surface-border)' }}>
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-600" />
<span className="text-sm text-theme">{errors.form}</span>
</div>
)}
{/* Personal — required: display name + password */}
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-theme flex items-center gap-2">
<UserIcon className="w-4 h-4" />
{t('customer.acceptInvite.section.personal', 'Personal')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.salutation', 'Salutation')}
</label>
<select
value={form.salutation}
onChange={(e) => update('salutation', e.target.value)}
className="w-full rounded-lg border px-3 h-10 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
color: 'var(--color-text)',
}}
>
{SALUTATION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{t(o.labelKey, o.fallback)}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.acceptInvite.displayName', 'Display name')} <span className="text-red-500">*</span>
</label>
<Input
value={form.display_name}
onChange={(e) => update('display_name', e.target.value)}
error={errors.display_name}
autoComplete="name"
/>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-first-name">
{t('customer.profile.field.firstName', 'First name')}
</label>
<Input
id="invite-first-name"
name="given-name"
autoComplete="given-name"
value={form.first_name}
onChange={(e) => update('first_name', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-last-name">
{t('customer.profile.field.lastName', 'Last name')}
</label>
<Input
id="invite-last-name"
name="family-name"
autoComplete="family-name"
value={form.last_name}
onChange={(e) => update('last_name', e.target.value)}
/>
</div>
</div>
</section>
{/* Contact — all optional, the photographer will probably
appreciate having the phone for last-minute schedule
changes but no customer should be blocked on it. */}
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-theme flex items-center gap-2">
<Phone className="w-4 h-4" />
{t('customer.acceptInvite.section.contact', 'Contact & business (optional)')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-phone">
{t('customer.profile.field.phone', 'Phone')}
</label>
<Input
id="invite-phone"
name="tel"
type="tel"
autoComplete="tel"
value={form.phone}
onChange={(e) => update('phone', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-company">
{t('customer.profile.field.companyName', 'Company name')}
</label>
<Input
id="invite-company"
name="organization"
autoComplete="organization"
value={form.company_name}
onChange={(e) => update('company_name', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-vat">
{t('customer.profile.field.vatId', 'VAT ID')}
</label>
<Input
id="invite-vat"
name="vat-id"
value={form.vat_id}
onChange={(e) => update('vat_id', e.target.value)}
/>
</div>
</div>
</section>
{/* Address */}
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-theme flex items-center gap-2">
<MapPin className="w-4 h-4" />
{t('customer.acceptInvite.section.address', 'Billing address (optional)')}
</h2>
{/* Same `name`+`autoComplete` pairing the profile page
uses — see CustomerProfilePage for the rationale. */}
<div className="grid grid-cols-1 sm:grid-cols-6 gap-3">
<div className="sm:col-span-6">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-address-line1">
{t('customer.profile.field.addressLine1', 'Address line 1')}
</label>
<Input
id="invite-address-line1"
name="address-line1"
autoComplete="billing address-line1"
value={form.address_line1}
onChange={(e) => update('address_line1', e.target.value)}
/>
</div>
<div className="sm:col-span-6">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-address-line2">
{t('customer.profile.field.addressLine2', 'Address line 2')}
</label>
<Input
id="invite-address-line2"
name="address-line2"
autoComplete="billing address-line2"
value={form.address_line2}
onChange={(e) => update('address_line2', e.target.value)}
/>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-postal-code">
{t('customer.profile.field.postalCode', 'Postal code')}
</label>
<Input
id="invite-postal-code"
name="postal-code"
autoComplete="billing postal-code"
inputMode="numeric"
value={form.postal_code}
onChange={(e) => update('postal_code', e.target.value)}
/>
</div>
<div className="sm:col-span-3">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-city">
{t('customer.profile.field.city', 'City')}
</label>
<Input
id="invite-city"
name="address-level2"
autoComplete="billing address-level2"
value={form.city}
onChange={(e) => update('city', e.target.value)}
/>
</div>
<div className="sm:col-span-1">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-country">
{t('customer.profile.field.countryCode', 'Country')}
</label>
<Input
id="invite-country"
name="country"
autoComplete="billing country"
placeholder="DE"
maxLength={2}
value={form.country_code}
onChange={(e) => update('country_code', e.target.value.toUpperCase().slice(0, 2))}
/>
</div>
<div className="sm:col-span-3">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-state">
{t('customer.profile.field.state', 'State / region')}
</label>
<Input
id="invite-state"
name="address-level1"
autoComplete="billing address-level1"
value={form.state}
onChange={(e) => update('state', e.target.value)}
/>
</div>
</div>
</section>
{/* Password — required */}
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-theme flex items-center gap-2">
<Lock className="w-4 h-4" />
{t('customer.acceptInvite.section.password', 'Choose a password')}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.acceptInvite.password', 'Password')} <span className="text-red-500">*</span>
</label>
<Input
type="password"
value={form.password}
onChange={(e) => update('password', e.target.value)}
error={errors.password}
autoComplete="new-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.acceptInvite.confirm', 'Confirm password')} <span className="text-red-500">*</span>
</label>
<Input
type="password"
value={form.confirm}
onChange={(e) => update('confirm', e.target.value)}
error={errors.confirm}
autoComplete="new-password"
/>
</div>
</div>
<p className="text-xs text-muted-theme">
{t('customer.acceptInvite.passwordHint', 'At least 8 characters, with one uppercase letter and one number.')}
</p>
</section>
<Button type="submit" variant="primary" size="lg" isLoading={isSubmitting} className="w-full">
{t('customer.acceptInvite.submit', 'Create account')}
</Button>
</form>
)}
</Card>
</div>
</div>
);
};
/**
* Translate the snake_case prefill payload (matches the backend wire shape)
* into the camelCase-ish form fields the local state uses. Kept inline
* because it's only ever called once on mount.
*/
function mergePrefillIntoForm(current: FormState, prefill: CustomerProfilePrefill): Partial<FormState> {
const out: Partial<FormState> = {};
if (prefill.salutation && !current.salutation) out.salutation = prefill.salutation;
if (prefill.first_name && !current.first_name) out.first_name = prefill.first_name;
if (prefill.last_name && !current.last_name) out.last_name = prefill.last_name;
if (prefill.display_name && !current.display_name) out.display_name = prefill.display_name;
if (prefill.phone && !current.phone) out.phone = prefill.phone;
if (prefill.company_name && !current.company_name) out.company_name = prefill.company_name;
if (prefill.vat_id && !current.vat_id) out.vat_id = prefill.vat_id;
if (prefill.address_line1 && !current.address_line1) out.address_line1 = prefill.address_line1;
if (prefill.address_line2 && !current.address_line2) out.address_line2 = prefill.address_line2;
if (prefill.postal_code && !current.postal_code) out.postal_code = prefill.postal_code;
if (prefill.city && !current.city) out.city = prefill.city;
if (prefill.state && !current.state) out.state = prefill.state;
if (prefill.country_code && !current.country_code) out.country_code = prefill.country_code;
return out;
}
export default CustomerAcceptInvitePage;
@@ -0,0 +1,15 @@
import React from 'react';
import { Receipt } from 'lucide-react';
import { CustomerComingSoonPage } from './CustomerComingSoonPage';
export const CustomerBillsPage: React.FC = () => (
<CustomerComingSoonPage
titleKey="customer.bills.title"
titleFallback="Bills"
bodyKey="customer.bills.body"
bodyFallback="Invoices and payment history for your sessions will be available here. We'll send you an email when this is live."
icon={Receipt}
/>
);
export default CustomerBillsPage;
@@ -0,0 +1,15 @@
import React from 'react';
import { Calendar } from 'lucide-react';
import { CustomerComingSoonPage } from './CustomerComingSoonPage';
export const CustomerCalendarPage: React.FC = () => (
<CustomerComingSoonPage
titleKey="customer.calendar.title"
titleFallback="Calendar"
bodyKey="customer.calendar.body"
bodyFallback="Upcoming sessions, gallery delivery dates, and other shoot-related events will land here. We'll let you know when it's ready."
icon={Calendar}
/>
);
export default CustomerCalendarPage;
@@ -0,0 +1,60 @@
/**
* Generic placeholder for customer-surface features that aren't built yet
* but are surfaced in the sidebar so the maintainer can demo the layout
* without the feature being live (Calendar, Quotes, Bills — #354 follow-ups).
*
* Single component re-used for all three; the calling page passes the title
* + lucide icon so each route stays distinguishable in the address bar and
* heading.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
interface CustomerComingSoonPageProps {
titleKey: string;
titleFallback: string;
bodyKey: string;
bodyFallback: string;
icon: React.ComponentType<{ className?: string }>;
}
export const CustomerComingSoonPage: React.FC<CustomerComingSoonPageProps> = ({
titleKey, titleFallback, bodyKey, bodyFallback, icon: Icon,
}) => {
const { t } = useTranslation();
return (
<div className="container py-8 sm:py-16">
<div
className="max-w-xl mx-auto rounded-xl border p-8 sm:p-12 text-center"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
}}
>
<div
className="mx-auto mb-4 w-14 h-14 rounded-full flex items-center justify-center"
style={{ backgroundColor: 'color-mix(in srgb, var(--color-accent) 14%, transparent)' }}
>
<Icon className="w-7 h-7" style={{ color: 'var(--color-accent)' }} />
</div>
<span
className="inline-block text-[10px] uppercase tracking-wider px-2 py-0.5 rounded font-semibold mb-3"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-accent) 14%, transparent)',
color: 'var(--color-accent)',
}}
>
{t('customer.comingSoon.tag', 'Coming soon')}
</span>
<h1 className="text-2xl font-bold text-theme mb-2">
{t(titleKey, titleFallback)}
</h1>
<p className="text-sm text-muted-theme leading-relaxed">
{t(bodyKey, bodyFallback)}
</p>
</div>
</div>
);
};
export default CustomerComingSoonPage;
@@ -0,0 +1,304 @@
/**
* Customer dashboard (#354) — list of every gallery the admin has granted
* this customer access to. Mounted at /customer/dashboard.
*
* Now a layout-wrapped page (Outlet child of CustomerLayout) — no inner
* <CustomerLayout> wrapper.
*
* Design changes (#354 follow-up):
* - inline list rows instead of card grid (the maintainer asked for a
* denser, more spreadsheet-like view — works better when a customer
* has many recurring weddings)
* - sort dropdown: Name / Newest first / Oldest first
* - per-row Open + Download buttons (download bypasses the gallery and
* bundles a zip in one click)
*
* Card click → exchange the customer JWT for a per-event gallery JWT via
* /api/customer/events/:slug/access-token, the backend writes the
* gallery_token_<slug> cookie alongside the JSON response, then we navigate
* to /gallery/:slug. The gallery code path needs no changes — it sees a
* regular gallery token exactly as if the per-event password had been
* entered.
*/
import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Calendar, Clock, Download, ExternalLink, ImageIcon, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { format, parseISO } from 'date-fns';
import { useQuery } from '@tanstack/react-query';
import { Button, Loading } from '../../components/common';
import { customerService, type CustomerEvent } from '../../services/customer.service';
import { galleryService } from '../../services/gallery.service';
import { storeGalleryToken, setActiveGallerySlug } from '../../utils/galleryAuthStorage';
type SortKey = 'newest' | 'oldest' | 'name';
const SORT_OPTIONS: Array<{ value: SortKey; labelKey: string; fallback: string }> = [
{ value: 'newest', labelKey: 'customer.dashboard.sortNewest', fallback: 'Newest first' },
{ value: 'oldest', labelKey: 'customer.dashboard.sortOldest', fallback: 'Oldest first' },
{ value: 'name', labelKey: 'customer.dashboard.sortName', fallback: 'By name' },
];
/**
* Default to newest-first because that's almost always what a returning
* customer wants ("which gallery did they upload yesterday?"). The other
* orderings are mostly useful for archival browsing.
*/
const DEFAULT_SORT: SortKey = 'newest';
export const CustomerDashboardPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { data: events, isLoading, error } = useQuery({
queryKey: ['customer-events'],
queryFn: () => customerService.listEvents(),
});
const [openingSlug, setOpeningSlug] = useState<string | null>(null);
const [downloadingSlug, setDownloadingSlug] = useState<string | null>(null);
const [sort, setSort] = useState<SortKey>(DEFAULT_SORT);
const sortedEvents = useMemo(() => {
const list: CustomerEvent[] = (events || []).slice();
// Use eventDate (the wedding/shoot date) as the primary key for date
// sorts; fall back to assignedAt when the event has no date set so
// entries don't all collapse to the bottom. Name sort is a basic
// case-insensitive locale compare.
const dateOf = (e: CustomerEvent) => (e.eventDate || e.assignedAt || '');
if (sort === 'newest') {
list.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
} else if (sort === 'oldest') {
list.sort((a, b) => dateOf(a).localeCompare(dateOf(b)));
} else {
list.sort((a, b) => a.eventName.localeCompare(b.eventName, undefined, { sensitivity: 'base' }));
}
return list;
}, [events, sort]);
const openEvent = async (slug: string) => {
if (openingSlug) return;
setOpeningSlug(slug);
try {
const { token } = await customerService.getEventAccessToken(slug);
storeGalleryToken(slug, token);
setActiveGallerySlug(slug);
navigate(`/gallery/${encodeURIComponent(slug)}`);
} catch (e: any) {
const status = e?.response?.status;
if (status === 410) {
toast.error(t('customer.dashboard.eventExpired', 'This gallery has expired.'));
} else if (status === 403) {
toast.error(t('customer.dashboard.eventForbidden', 'You no longer have access to this gallery.'));
} else {
toast.error(t('customer.dashboard.openError', 'Could not open this gallery. Please try again.'));
}
} finally {
setOpeningSlug(null);
}
};
const quickDownload = async (slug: string, eventName: string) => {
if (downloadingSlug) return;
setDownloadingSlug(slug);
try {
const { token } = await customerService.getEventAccessToken(slug);
storeGalleryToken(slug, token);
setActiveGallerySlug(slug);
await galleryService.downloadAllPhotos(slug, false);
toast.success(t('customer.dashboard.downloadStarted', 'Download started for {{name}}', { name: eventName }));
} catch (e: any) {
const status = e?.response?.status;
if (status === 410) {
toast.error(t('customer.dashboard.eventExpired', 'This gallery has expired.'));
} else if (status === 403) {
toast.error(t('customer.dashboard.eventForbidden', 'You no longer have access to this gallery.'));
} else {
toast.error(t('customer.dashboard.downloadError', 'Could not start the download. Please try again.'));
}
} finally {
setDownloadingSlug(null);
}
};
const formatDate = (iso: string | null) => {
if (!iso) return null;
try { return format(parseISO(iso), 'PP'); } catch { return null; }
};
return (
<div className="container py-6 sm:py-8">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3 mb-6">
<div>
<h1 className="text-2xl font-bold text-theme">
{t('customer.dashboard.title', 'Your galleries')}
</h1>
<p className="mt-1 text-sm text-muted-theme">
{t('customer.dashboard.subtitle', 'Click a gallery to open it. The Download button bundles every photo as a zip.')}
</p>
</div>
{/* Sort dropdown — only render when there's something to sort. */}
{(events?.length || 0) > 1 && (
<div className="flex items-center gap-2">
<label htmlFor="customer-events-sort" className="text-sm text-muted-theme whitespace-nowrap">
{t('customer.dashboard.sortLabel', 'Sort by')}
</label>
<select
id="customer-events-sort"
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
className="rounded-lg border px-3 h-9 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
color: 'var(--color-text)',
}}
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{t(opt.labelKey, opt.fallback)}
</option>
))}
</select>
</div>
)}
</div>
{isLoading ? (
<div className="flex justify-center py-16"><Loading size="lg" /></div>
) : error ? (
<div
role="alert"
className="rounded-xl border p-6 flex items-start gap-3"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
}}
>
<AlertCircle className="w-5 h-5 mt-0.5 flex-shrink-0 text-red-500" />
<p className="text-theme">
{t('customer.dashboard.loadError', 'Could not load your galleries. Please try again.')}
</p>
</div>
) : (sortedEvents.length === 0) ? (
<div
className="rounded-xl border p-6"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
}}
>
<div className="text-center py-12">
<ImageIcon className="w-12 h-12 mx-auto mb-3 text-muted-theme" aria-hidden="true" />
<h2 className="text-lg font-semibold text-theme mb-2">
{t('customer.dashboard.emptyTitle', 'No galleries yet')}
</h2>
<p className="text-sm text-muted-theme">
{t(
'customer.dashboard.emptyBody',
'Once your photographer assigns you to a gallery, it will appear here.'
)}
</p>
</div>
</div>
) : (
// Inline list — one row per gallery, no card grid. Hover affordance
// via the entire row acting as a button (Open) plus a separate
// Download icon button so click bubbling doesn't cross-trigger.
<div
className="rounded-xl border overflow-hidden"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
}}
>
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
{sortedEvents.map((ev) => {
const date = formatDate(ev.eventDate);
const expires = formatDate(ev.expiresAt);
const isExpired = ev.expiresAt ? new Date(ev.expiresAt) < new Date() : false;
const isOpening = openingSlug === ev.slug;
const isDownloading = downloadingSlug === ev.slug;
const rowDisabled = isExpired || openingSlug !== null || downloadingSlug !== null;
return (
<li
key={ev.id}
className="px-4 py-3 sm:px-5 sm:py-4 flex items-center gap-3 sm:gap-4"
style={{
borderColor: 'var(--color-surface-border)',
opacity: isExpired ? 0.6 : 1,
}}
>
<div className="flex-1 min-w-0">
<h3 className="text-sm sm:text-base font-semibold text-theme truncate">
{ev.eventName}
</h3>
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs sm:text-sm text-muted-theme">
{date && (
<span className="inline-flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5 flex-shrink-0" />
{date}
</span>
)}
{expires && (
<span className="inline-flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
{isExpired
? t('customer.dashboard.expiredOn', 'Expired {{date}}', { date: expires })
: t('customer.dashboard.expiresOn', 'Expires {{date}}', { date: expires })}
</span>
)}
{isOpening && (
<span className="text-xs" style={{ color: 'var(--color-accent)' }}>
{t('customer.dashboard.opening', 'Opening…')}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{!isExpired && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => quickDownload(ev.slug, ev.eventName)}
disabled={rowDisabled}
leftIcon={<Download className="w-4 h-4" />}
aria-label={t('customer.dashboard.quickDownloadAria', 'Download all photos for {{name}}', { name: ev.eventName })}
>
<span className="hidden sm:inline">
{isDownloading
? t('customer.dashboard.preparingDownload', 'Preparing…')
: t('customer.dashboard.download', 'Download')}
</span>
</Button>
)}
<Button
type="button"
variant="primary"
size="sm"
onClick={() => openEvent(ev.slug)}
disabled={rowDisabled}
leftIcon={<ExternalLink className="w-4 h-4" />}
aria-label={t('customer.dashboard.openAria', 'Open gallery {{name}}', { name: ev.eventName })}
>
<span className="hidden sm:inline">
{t('customer.dashboard.open', 'Open')}
</span>
</Button>
</div>
</li>
);
})}
</ul>
</div>
)}
</div>
);
};
export default CustomerDashboardPage;
@@ -0,0 +1,269 @@
/**
* Customer surface shell (#354).
*
* Visually patterned after the admin layout (sidebar + top header + scrollable
* main) — the maintainer asked for parity with /admin/* so admins dogfooding
* the customer flow get a familiar structure. Differences from AdminLayout:
* - no AdminSidebar / RBAC permission gating; customers don't have roles
* - branding header (logo + company name) sits inside the sidebar so the
* customer surface looks like *their* photographer's site, not picpeak
* chrome
* - calendar / quotes / bills nav items are stubbed (coming-soon pages);
* they're shown to the user behind a small "Coming soon" tag because
* they're built but intentionally inert until the matching backends ship
*
* Renders as a layout route (Outlet pattern) so individual pages don't need
* to wrap their content in `<CustomerLayout>` — same approach AdminLayout uses.
*/
import React, { useState } from 'react';
import { Link, NavLink, Outlet, Navigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Calendar,
FileText,
Image as ImageIcon,
LogOut,
Menu,
Receipt,
User as UserIcon,
X,
} from 'lucide-react';
import { useCustomerAuth } from '../../contexts/CustomerAuthContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
interface NavItem {
to: string;
labelKey: string;
fallback: string;
icon: React.ComponentType<{ className?: string }>;
/**
* Optional gate — entry only renders when the matching feature is
* effective for this customer (i.e. global toggle ON and per-customer
* flag ON, AND-combined server-side in /api/customer/auth/session).
* Galleries + Profile are always visible; Calendar/Quotes/Bills are
* gated.
*/
feature?: 'calendar' | 'quotes' | 'bills';
}
const NAV: NavItem[] = [
{ to: '/customer/dashboard', labelKey: 'customer.nav.galleries', fallback: 'Galleries', icon: ImageIcon },
{ to: '/customer/calendar', labelKey: 'customer.nav.calendar', fallback: 'Calendar', icon: Calendar, feature: 'calendar' },
{ to: '/customer/quotes', labelKey: 'customer.nav.quotes', fallback: 'Quotes', icon: FileText, feature: 'quotes' },
{ to: '/customer/bills', labelKey: 'customer.nav.bills', fallback: 'Bills', icon: Receipt, feature: 'bills' },
{ to: '/customer/profile', labelKey: 'customer.nav.profile', fallback: 'Profile', icon: UserIcon },
];
export const CustomerLayout: React.FC = () => {
const { t } = useTranslation();
const location = useLocation();
const { customer, features, branding, isAuthenticated, isLoading, logout } = useCustomerAuth();
const { data: settingsData } = usePublicSettings();
const [sidebarOpen, setSidebarOpen] = useState(false);
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = settingsData?.branding_logo_url?.trim();
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
// Filter out feature-gated entries the customer can't see. Galleries +
// Profile have no feature property, so they're always present.
const visibleNav = NAV.filter((item) => !item.feature || features[item.feature] === true);
// Branding visibility — admin can hide either piece independently. If
// both are hidden the brand link still exists (you can click it to
// reach /customer/dashboard) but renders empty space at zero height.
const showLogo = branding.showLogo;
const showCompanyName = branding.showCompanyName;
// Loading screen mirrors AdminLayout's so admin-as-customer dogfooding
// sees a familiar transition. Background uses the theme variable so a
// dark Branding palette doesn't flash white on first paint.
if (isLoading) {
return (
<div
className="min-h-screen flex items-center justify-center"
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
>
<div className="w-12 h-12 border-4 border-t-transparent rounded-full animate-spin" style={{ borderColor: 'var(--color-accent)', borderTopColor: 'transparent' }} />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/customer/login" replace />;
}
const greetingName = customer?.displayName
|| customer?.firstName
|| (customer?.email ? customer.email.split('@')[0] : '');
return (
<div
// The `customer-surface` marker is read by index.css to retheme
// <Input> components via CSS variables — admin uses tailwind's
// `dark:` modifier (toggled on <html>), but the customer surface
// uses theme tokens so we scope the override here.
className="customer-surface h-screen flex overflow-hidden"
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
>
{/* Mobile backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 w-64 border-r transform transition-transform duration-200 ease-in-out lg:relative lg:translate-x-0 lg:h-screen ${
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
style={{
backgroundColor: 'var(--color-surface, #ffffff)',
borderColor: 'var(--color-surface-border, #e5e5e5)',
}}
>
<div className="flex flex-col h-screen lg:h-full">
{/* Brand */}
<div
className="flex items-center justify-between h-16 px-4 border-b flex-shrink-0"
style={{ borderColor: 'var(--color-surface-border, #e5e5e5)' }}
>
<Link
to="/customer/dashboard"
className="flex items-center gap-2 min-w-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 rounded"
onClick={() => setSidebarOpen(false)}
>
{showLogo && (
<img
src={resolvedLogoUrl}
alt={companyName}
className="h-8 w-auto object-contain flex-shrink-0"
/>
)}
{showCompanyName && (
<span className="text-sm font-semibold text-theme truncate">
{companyName}
</span>
)}
</Link>
<button
type="button"
onClick={() => setSidebarOpen(false)}
className="lg:hidden text-muted-theme hover:text-theme"
aria-label={t('common.close', 'Close')}
>
<X className="w-6 h-6" />
</button>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto min-h-0">
{visibleNav.map((item) => {
const Icon = item.icon;
// Active when the path matches or starts with the entry —
// dashboard stays active only on exact match so the other
// nav entries don't double-highlight on /customer/dashboard.
const isActive = item.to === '/customer/dashboard'
? location.pathname === item.to
: location.pathname === item.to || location.pathname.startsWith(`${item.to}/`);
return (
<NavLink
key={item.to}
to={item.to}
onClick={() => setSidebarOpen(false)}
className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={isActive ? {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 12%, transparent)',
color: 'var(--color-accent)',
} : undefined}
>
<Icon className={`w-5 h-5 flex-shrink-0 ${isActive ? '' : 'text-muted-theme'}`} />
<span className={`flex-1 truncate ${isActive ? '' : 'text-theme'}`}>
{t(item.labelKey, item.fallback)}
</span>
{/* Coming-soon pill for the gated entries. The pages
themselves are still placeholders even when the
admin has enabled access — the badge keeps that
promise honest. */}
{item.feature && (
<span
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold"
style={{
backgroundColor: 'color-mix(in srgb, var(--color-accent) 14%, transparent)',
color: 'var(--color-accent)',
}}
>
{t('customer.nav.soon', 'Soon')}
</span>
)}
</NavLink>
);
})}
</nav>
{/* Footer (logout + greeting on a single line, mirrors admin) */}
<div
className="border-t px-4 py-3 flex items-center justify-between gap-2"
style={{ borderColor: 'var(--color-surface-border, #e5e5e5)' }}
>
<div className="min-w-0">
<div className="text-sm font-medium text-theme truncate">{greetingName}</div>
<div className="text-xs text-muted-theme truncate">{customer?.email}</div>
</div>
<button
type="button"
onClick={() => { void logout(); }}
className="p-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 text-muted-theme hover:text-theme focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
aria-label={t('common.logout', 'Logout')}
title={t('common.logout', 'Logout')}
>
<LogOut className="w-5 h-5" />
</button>
</div>
</div>
</aside>
{/* Main column */}
<div className="flex-1 flex flex-col min-w-0 h-screen">
<header
className="lg:hidden h-14 px-4 flex items-center justify-between border-b flex-shrink-0"
style={{
backgroundColor: 'var(--color-surface, #ffffff)',
borderColor: 'var(--color-surface-border, #e5e5e5)',
}}
>
<button
type="button"
onClick={() => setSidebarOpen(true)}
className="p-2 -ml-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 text-theme"
aria-label={t('common.menu', 'Menu')}
>
<Menu className="w-6 h-6" />
</button>
<span className="text-sm font-semibold text-theme truncate">{companyName}</span>
<span className="w-9" aria-hidden="true" />
</header>
<main id="customer-main" className="flex-1 overflow-y-auto">
<Outlet />
</main>
<footer
className="py-4 px-4 text-center text-xs"
style={{ color: 'var(--color-muted-text, #737373)' }}
>
<p>
{settingsData?.branding_footer_text
|| `© ${new Date().getFullYear()} ${companyName}. All rights reserved.`}
</p>
</footer>
</div>
</div>
);
};
export default CustomerLayout;
@@ -0,0 +1,258 @@
/**
* Customer login page (#354).
*
* Mounted at /customer/login. Strictly separate from /admin/login —
* different auth context, different cookie, different backend route.
*/
import React, { useState } from 'react';
import { Navigate, useSearchParams } from 'react-router-dom';
import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useCustomerAuth } from '../../contexts/CustomerAuthContext';
import { customerService } from '../../services/customer.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
export const CustomerLoginPage: React.FC = () => {
const { t } = useTranslation();
const { isAuthenticated, setSession } = useCustomerAuth();
const [searchParams] = useSearchParams();
const [formData, setFormData] = useState({ email: '', password: '' });
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
const { data: settingsData } = usePublicSettings();
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = settingsData?.branding_logo_url?.trim();
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
// After /accept-invite the user is redirected here with ?accepted=1
// so we can show a friendly success toast on first paint.
React.useEffect(() => {
if (searchParams.get('accepted') === '1') {
toast.success(t('customer.login.acceptedToast', 'Account ready — please log in.'));
}
}, [searchParams, t]);
if (isAuthenticated) {
return <Navigate to="/customer/dashboard" replace />;
}
const validateForm = (): boolean => {
const next: Record<string, string> = {};
if (!formData.email) {
next.email = t('customer.login.emailRequired', 'Email is required');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
next.email = t('customer.login.invalidEmail', 'Please enter a valid email');
}
if (!formData.password) {
next.password = t('customer.login.passwordRequired', 'Password is required');
}
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
toast.dismiss();
if (!validateForm()) return;
setIsLoading(true);
setErrors({});
try {
const response = await customerService.login(
formData.email,
formData.password,
recaptchaToken
);
// Apply the full session payload (customer + features + branding)
// so the dashboard's first paint shows the correct sidebar. Using
// setCustomer alone left features at DEFAULT_FEATURES (all false)
// until the next CustomerAuthProvider remount, which is why the
// Soon menus only appeared after navigating to a gallery and back.
setSession(response);
toast.success(t('customer.login.loginSuccess', 'Welcome back!'));
// Navigate via Navigate component on next render — setCustomer
// flips isAuthenticated true so the redirect at the top fires.
} catch (error: any) {
if (error.response?.status === 429 || error.response?.status === 423) {
toast.error(t('customer.login.tooManyAttempts', 'Too many attempts — please try again later.'));
} else if (error.response?.status === 401) {
setErrors({ form: t('customer.login.invalidCredentials', 'Invalid email or password') });
} else if (error.code === 'ERR_NETWORK') {
toast.error(t('customer.login.networkError', 'Could not reach the server. Please try again.'));
} else {
toast.error(t('customer.login.generalError', 'Login failed. Please try again.'));
}
} finally {
setIsLoading(false);
}
};
const handleInputChange = (field: 'email' | 'password') =>
(e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
if (errors[field]) setErrors((prev) => ({ ...prev, [field]: '' }));
};
return (
<div
// Visual structure mirrors AdminLoginPage so admin and customer
// landings feel like the same product: tinted logo frame above
// the title, "Need help?" support email below the form, "Powered
// by PicPeak" footer line. The .customer-surface marker class
// lets the global stylesheet retheme <Card>/<Input> for dark
// backgrounds (admin uses the dark: trigger; customer uses theme
// tokens).
className="customer-surface min-h-screen flex items-center justify-center p-4"
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
>
<div className="w-full max-w-md">
{/* Logo / header — matches AdminLoginPage's tinted square frame
so the brand presentation is identical across admin and
customer entry points. The frame itself is admin-controllable
via Branding → "Show tinted frame behind login logo" — same
toggle drives both login pages. */}
<div className="text-center mb-8">
{settingsData?.branding_login_logo_frame_enabled !== false ? (
<div
className="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: '#eee6d2' }}
>
<img
src={resolvedLogoUrl}
alt={companyName}
className="w-[180px] h-[130px] object-contain"
/>
</div>
) : (
<img
src={resolvedLogoUrl}
alt={companyName}
className="h-24 w-auto object-contain mx-auto mb-6"
/>
)}
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>
{t('customer.login.title', 'Customer login')}
</h1>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
{t('customer.login.subtitle', 'Access all of your photo galleries in one place.')}
</p>
</div>
<Card padding="lg">
<form onSubmit={handleSubmit} className="space-y-6">
{errors.form && (
<div
role="alert"
className="flex items-start gap-2 p-3 rounded-lg border"
style={{
borderColor: 'var(--color-surface-border, #e5e5e5)',
color: 'var(--color-text)',
backgroundColor: 'var(--color-elevated, rgba(220, 38, 38, 0.05))',
}}
>
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-600" />
<span className="text-sm">{errors.form}</span>
</div>
)}
<div>
<label htmlFor="customer-email" className="block text-sm font-medium text-theme mb-1">
{t('customer.login.email', 'Email')}
</label>
<Input
id="customer-email"
name="email"
type="email"
value={formData.email}
onChange={handleInputChange('email')}
error={errors.email}
placeholder={t('customer.login.emailPlaceholder', '[email protected]')}
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
autoComplete="email"
autoFocus
/>
</div>
<div>
<label htmlFor="customer-password" className="block text-sm font-medium text-theme mb-1">
{t('customer.login.password', 'Password')}
</label>
<div className="relative">
<Input
id="customer-password"
name="current-password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleInputChange('password')}
error={errors.password}
placeholder={t('customer.login.passwordPlaceholder', 'Your password')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword((p) => !p)}
className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600 transition-colors"
tabIndex={-1}
aria-label={showPassword
? t('customer.login.hidePassword', 'Hide password')
: t('customer.login.showPassword', 'Show password')}
>
{showPassword
? <EyeOff className="w-5 h-5" />
: <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<ReCaptcha
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
/>
<Button
type="submit"
variant="primary"
size="lg"
isLoading={isLoading}
className="w-full"
>
{t('customer.login.signIn', 'Sign in')}
</Button>
</form>
</Card>
{/* Footer — mirrors AdminLoginPage. Support email links to
mailto: with the address from Branding settings; falls back
to a placeholder so the link is never broken. The
"admin hint" line that used to live here is gone — admins
who land here on purpose can navigate to /admin/login on
their own. */}
<div className="text-center mt-8">
<p className="text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
{t('customer.login.needHelp', 'Need help?')}{' '}
<a
href={`mailto:${settingsData?.branding_support_email || '[email protected]'}`}
className="hover:underline"
style={{ color: 'var(--color-primary, #5C8762)' }}
>
{settingsData?.branding_support_email || '[email protected]'}
</a>
</p>
<p className="text-xs mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
{t('customer.login.poweredBy', 'Powered by PicPeak')}
</p>
</div>
</div>
</div>
);
};
export default CustomerLoginPage;
@@ -0,0 +1,536 @@
/**
* Customer self-service profile (#354 follow-up).
*
* Mounted at /customer/profile. Lets the logged-in customer edit:
* - personal name (salutation / first / last / display)
* - contact (phone, company, VAT id)
* - billing address
* - password
*
* The layout intentionally mirrors the admin detail pages (sectioned
* Cards, two-column form on wide screens, save buttons inside each section
* so a customer who only wants to fix their phone number doesn't have to
* scroll past the address). Email is read-only here — changing the login
* credential is admin-only for the same reason it is on AdminUserDetail.
*
* Two endpoints are hit:
* - PUT /api/customer/profile (name + contact + address)
* - POST /api/customer/profile/password (password change with re-auth)
*
* The password section bumps password_changed_at on the server, which
* silently logs other browser sessions out of this customer account on
* their next request. The current session keeps its cookie so the user
* doesn't get bounced to login mid-flow.
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Lock, Save, User as UserIcon, MapPin, Phone, Mail } from 'lucide-react';
import { Button, Input, Loading } from '../../components/common';
/**
* Inline tile wrapper used in place of <Card> on this page.
*
* The global .card class (used by <Card>) hard-codes bg-white + neutral
* borders, which fights the dark theme on the customer surface (the same
* fix the dashboard already shipped). Pinning to the theme tokens here
* keeps every section card consistent with the sidebar and the
* dashboard.
*/
const ProfileTile: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div
className="rounded-xl border p-6 sm:p-8"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
}}
>
{children}
</div>
);
import {
customerService,
type CustomerProfileFull,
type CustomerProfileUpdate,
} from '../../services/customer.service';
const SALUTATION_OPTIONS = [
{ value: '', labelKey: 'customer.profile.salutation.none', fallback: '— Not specified —' },
{ value: 'Herr', labelKey: 'customer.profile.salutation.herr', fallback: 'Herr' },
{ value: 'Frau', labelKey: 'customer.profile.salutation.frau', fallback: 'Frau' },
{ value: 'Mx', labelKey: 'customer.profile.salutation.mx', fallback: 'Mx' },
{ value: 'Dr', labelKey: 'customer.profile.salutation.dr', fallback: 'Dr.' },
];
/** Normalise a server profile into the local form-state shape (string | ''). */
function profileToForm(p: CustomerProfileFull): CustomerProfileUpdate {
return {
salutation: p.salutation ?? '',
firstName: p.firstName ?? '',
lastName: p.lastName ?? '',
displayName: p.displayName ?? '',
phone: p.phone ?? '',
companyName: p.companyName ?? '',
vatId: p.vatId ?? '',
addressLine1: p.addressLine1 ?? '',
addressLine2: p.addressLine2 ?? '',
postalCode: p.postalCode ?? '',
city: p.city ?? '',
state: p.state ?? '',
countryCode: p.countryCode ?? '',
preferredLanguage: p.preferredLanguage ?? 'en',
};
}
export const CustomerProfilePage: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const { data: profile, isLoading, error } = useQuery({
queryKey: ['customer-profile'],
queryFn: () => customerService.getProfile(),
});
// Local form state — initialised from server profile, edited freely until
// the user clicks Save. We keep it as a single object to make the diffing
// for the PUT call straightforward.
const [form, setForm] = useState<CustomerProfileUpdate>({});
const [savingProfile, setSavingProfile] = useState(false);
const [profileErr, setProfileErr] = useState<string | null>(null);
// Password change is its own mini-form. Kept separate so the main save
// doesn't accidentally sweep up half-typed password fields.
const [pwForm, setPwForm] = useState({ current: '', next: '', confirm: '' });
const [pwErrors, setPwErrors] = useState<Record<string, string>>({});
const [savingPassword, setSavingPassword] = useState(false);
useEffect(() => {
if (profile) setForm(profileToForm(profile));
}, [profile]);
const updateField = (key: keyof CustomerProfileUpdate, value: string) => {
setForm((p) => ({ ...p, [key]: value }));
};
const handleProfileSave = async (e: React.FormEvent) => {
e.preventDefault();
setProfileErr(null);
setSavingProfile(true);
try {
// Send empty strings as null so the server clears the row instead of
// storing whitespace. The backend already coerces empty strings, but
// doing it client-side keeps the request payload honest.
const payload: CustomerProfileUpdate = {};
for (const [k, v] of Object.entries(form)) {
const key = k as keyof CustomerProfileUpdate;
payload[key] = (typeof v === 'string' && v.trim() === '') ? null : v as any;
}
const updated = await customerService.updateProfile(payload);
setForm(profileToForm(updated));
qc.invalidateQueries({ queryKey: ['customer-profile'] });
toast.success(t('customer.profile.savedToast', 'Profile saved'));
} catch (err: any) {
setProfileErr(err?.response?.data?.error || t('customer.profile.saveError', 'Could not save profile.'));
} finally {
setSavingProfile(false);
}
};
const validatePassword = (): boolean => {
const next: Record<string, string> = {};
if (!pwForm.current) {
next.current = t('customer.profile.password.currentRequired', 'Enter your current password');
}
if (pwForm.next.length < 8) {
next.next = t('customer.profile.password.tooShort', 'At least 8 characters');
}
if (pwForm.next !== pwForm.confirm) {
next.confirm = t('customer.profile.password.mismatch', 'Passwords do not match');
}
setPwErrors(next);
return Object.keys(next).length === 0;
};
const handlePasswordSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!validatePassword()) return;
setSavingPassword(true);
try {
await customerService.changePassword(pwForm.current, pwForm.next);
setPwForm({ current: '', next: '', confirm: '' });
setPwErrors({});
toast.success(t('customer.profile.password.savedToast', 'Password updated'));
} catch (err: any) {
const status = err?.response?.status;
if (status === 401) {
setPwErrors({ current: t('customer.profile.password.wrong', 'Current password is incorrect') });
} else if (status === 400 && err?.response?.data?.details?.length) {
setPwErrors({ next: err.response.data.details.join(' ') });
} else {
toast.error(t('customer.profile.password.error', 'Could not change password'));
}
} finally {
setSavingPassword(false);
}
};
if (isLoading) {
return (
<div className="container py-12 flex justify-center">
<Loading size="lg" />
</div>
);
}
if (error || !profile) {
return (
<div className="container py-12">
<ProfileTile>
<p className="text-sm text-red-600">
{t('customer.profile.loadError', 'Could not load your profile.')}
</p>
</ProfileTile>
</div>
);
}
return (
<div className="container py-6 sm:py-8 space-y-6">
<div>
<h1 className="text-2xl font-bold text-theme">
{t('customer.profile.title', 'Customer profile')}
</h1>
<p className="mt-1 text-sm text-muted-theme">
{t('customer.profile.subtitle', 'Keep your contact and billing details up to date — they\'re shown on quotes and invoices once those features go live.')}
</p>
</div>
{/* Personal info + contact + address — single combined form so a
customer can update everything in one save. The visual sections
are inside the form purely for grouping. */}
<form onSubmit={handleProfileSave} className="space-y-6">
<ProfileTile>
<div className="flex items-center gap-2 mb-4">
<UserIcon className="w-5 h-5 text-muted-theme" />
<h2 className="text-lg font-semibold text-theme">
{t('customer.profile.section.personal', 'Personal information')}
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.field.email', 'Email (login)')}
</label>
<Input
value={profile.email}
readOnly
disabled
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
/>
<p className="mt-1 text-xs text-muted-theme">
{t('customer.profile.field.emailHint', 'Contact your photographer if you need to change your login email.')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-salutation">
{t('customer.profile.field.salutation', 'Salutation')}
</label>
<select
id="profile-salutation"
value={form.salutation || ''}
onChange={(e) => updateField('salutation', e.target.value)}
className="w-full rounded-lg border px-3 h-10 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
style={{
backgroundColor: 'var(--color-surface)',
borderColor: 'var(--color-surface-border)',
color: 'var(--color-text)',
}}
>
{SALUTATION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{t(o.labelKey, o.fallback)}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-first-name">
{t('customer.profile.field.firstName', 'First name')}
</label>
<Input
id="profile-first-name"
name="given-name"
autoComplete="given-name"
value={form.firstName || ''}
onChange={(e) => updateField('firstName', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-last-name">
{t('customer.profile.field.lastName', 'Last name')}
</label>
<Input
id="profile-last-name"
name="family-name"
autoComplete="family-name"
value={form.lastName || ''}
onChange={(e) => updateField('lastName', e.target.value)}
/>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-display-name">
{t('customer.profile.field.displayName', 'Display name')}
</label>
<Input
id="profile-display-name"
name="nickname"
autoComplete="nickname"
value={form.displayName || ''}
onChange={(e) => updateField('displayName', e.target.value)}
/>
<p className="mt-1 text-xs text-muted-theme">
{t('customer.profile.field.displayNameHint', 'How we greet you in the dashboard.')}
</p>
</div>
</div>
</ProfileTile>
<ProfileTile>
<div className="flex items-center gap-2 mb-4">
<Phone className="w-5 h-5 text-muted-theme" />
<h2 className="text-lg font-semibold text-theme">
{t('customer.profile.section.contact', 'Contact & business')}
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-phone">
{t('customer.profile.field.phone', 'Phone')}
</label>
<Input
id="profile-phone"
name="tel"
type="tel"
autoComplete="tel"
value={form.phone || ''}
onChange={(e) => updateField('phone', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-company">
{t('customer.profile.field.companyName', 'Company name')}
</label>
<Input
id="profile-company"
name="organization"
autoComplete="organization"
value={form.companyName || ''}
onChange={(e) => updateField('companyName', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-vat">
{t('customer.profile.field.vatId', 'VAT ID')}
</label>
{/* No standard autocomplete token for VAT — leave it off so
browsers don't try to fill it from a random saved value. */}
<Input
id="profile-vat"
name="vat-id"
value={form.vatId || ''}
onChange={(e) => updateField('vatId', e.target.value)}
/>
</div>
</div>
</ProfileTile>
<ProfileTile>
<div className="flex items-center gap-2 mb-4">
<MapPin className="w-5 h-5 text-muted-theme" />
<h2 className="text-lg font-semibold text-theme">
{t('customer.profile.section.address', 'Billing address')}
</h2>
</div>
{/*
Address autofill: browsers (especially Safari) need three things
to reliably fill a billing address:
1. Each input has a `name` attribute that matches a standard
form-autofill token (address-line1, postal-code, etc.).
2. The corresponding `autoComplete` attribute matches the
same token.
3. All address fields share an autocomplete *section* — we
prefix every token with `billing` so browser fills the
user's billing address rather than their shipping one
(Safari treats unprefixed and `shipping` as the default).
Without `name`+`id` matching, Safari heuristics give up and
fall back to nothing, which is what the maintainer hit.
*/}
<div className="grid grid-cols-1 sm:grid-cols-6 gap-4">
<div className="sm:col-span-6">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-address-line1">
{t('customer.profile.field.addressLine1', 'Address line 1')}
</label>
<Input
id="profile-address-line1"
name="address-line1"
autoComplete="billing address-line1"
value={form.addressLine1 || ''}
onChange={(e) => updateField('addressLine1', e.target.value)}
/>
</div>
<div className="sm:col-span-6">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-address-line2">
{t('customer.profile.field.addressLine2', 'Address line 2')}
</label>
<Input
id="profile-address-line2"
name="address-line2"
autoComplete="billing address-line2"
value={form.addressLine2 || ''}
onChange={(e) => updateField('addressLine2', e.target.value)}
/>
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-postal-code">
{t('customer.profile.field.postalCode', 'Postal code')}
</label>
<Input
id="profile-postal-code"
name="postal-code"
autoComplete="billing postal-code"
inputMode="numeric"
value={form.postalCode || ''}
onChange={(e) => updateField('postalCode', e.target.value)}
/>
</div>
<div className="sm:col-span-3">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-city">
{t('customer.profile.field.city', 'City')}
</label>
<Input
id="profile-city"
name="address-level2"
autoComplete="billing address-level2"
value={form.city || ''}
onChange={(e) => updateField('city', e.target.value)}
/>
</div>
<div className="sm:col-span-1">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-country">
{t('customer.profile.field.countryCode', 'Country')}
</label>
<Input
id="profile-country"
name="country"
autoComplete="billing country"
placeholder="DE"
maxLength={2}
value={form.countryCode || ''}
onChange={(e) => updateField('countryCode', e.target.value.toUpperCase().slice(0, 2))}
/>
</div>
<div className="sm:col-span-3">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-state">
{t('customer.profile.field.state', 'State / region')}
</label>
<Input
id="profile-state"
name="address-level1"
autoComplete="billing address-level1"
value={form.state || ''}
onChange={(e) => updateField('state', e.target.value)}
/>
</div>
</div>
</ProfileTile>
{profileErr && (
<p className="text-sm text-red-600">{profileErr}</p>
)}
<div className="flex justify-end">
<Button type="submit" variant="primary" leftIcon={<Save className="w-4 h-4" />} isLoading={savingProfile}>
{t('customer.profile.save', 'Save changes')}
</Button>
</div>
</form>
{/* Password change — separate form so it doesn't fight with the main
save button and the user's password autofill never leaks into
unrelated fields. */}
<ProfileTile>
<div className="flex items-center gap-2 mb-4">
<Lock className="w-5 h-5 text-muted-theme" />
<h2 className="text-lg font-semibold text-theme">
{t('customer.profile.section.password', 'Change password')}
</h2>
</div>
<form onSubmit={handlePasswordSave} className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.password.current', 'Current password')}
</label>
<Input
type="password"
value={pwForm.current}
onChange={(e) => setPwForm((p) => ({ ...p, current: e.target.value }))}
error={pwErrors.current}
autoComplete="current-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.password.next', 'New password')}
</label>
<Input
type="password"
value={pwForm.next}
onChange={(e) => setPwForm((p) => ({ ...p, next: e.target.value }))}
error={pwErrors.next}
autoComplete="new-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.profile.password.confirm', 'Confirm new password')}
</label>
<Input
type="password"
value={pwForm.confirm}
onChange={(e) => setPwForm((p) => ({ ...p, confirm: e.target.value }))}
error={pwErrors.confirm}
autoComplete="new-password"
/>
</div>
<div className="sm:col-span-3">
<p className="mt-1 text-xs text-muted-theme">
{t('customer.profile.password.hint', 'At least 8 characters with one uppercase letter and one number.')}
</p>
</div>
<div className="sm:col-span-3 flex justify-end">
<Button type="submit" variant="primary" leftIcon={<Lock className="w-4 h-4" />} isLoading={savingPassword}>
{t('customer.profile.password.submit', 'Update password')}
</Button>
</div>
</form>
</ProfileTile>
</div>
);
};
export default CustomerProfilePage;
@@ -0,0 +1,15 @@
import React from 'react';
import { FileText } from 'lucide-react';
import { CustomerComingSoonPage } from './CustomerComingSoonPage';
export const CustomerQuotesPage: React.FC = () => (
<CustomerComingSoonPage
titleKey="customer.quotes.title"
titleFallback="Quotes"
bodyKey="customer.quotes.body"
bodyFallback="Review and accept quotes for upcoming shoots in one place. We're still building this — for now, your photographer will keep sending quotes the usual way."
icon={FileText}
/>
);
export default CustomerQuotesPage;
@@ -0,0 +1,182 @@
/**
* Customer password reset (#354 follow-up).
*
* Mounted at /customer/reset-password/:token. Public route — anyone with
* the link can complete the reset. The token IS the auth: 256 bits of
* entropy, single-use, 7-day TTL, server-side validated; the existing
* password keeps working until this page successfully POSTs a new one.
*
* Mirrors CustomerAcceptInvitePage's chrome (logo + branded background)
* for visual consistency with the rest of the customer surface.
*/
import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Lock, AlertCircle, CheckCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card, Loading } from '../../components/common';
import { customerService } from '../../services/customer.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
export const CustomerResetPasswordPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { token = '' } = useParams<{ token: string }>();
const [reset, setReset] = useState<{ email: string; expiresAt: string } | null>(null);
const [lookupError, setLookupError] = useState<string | null>(null);
const [isLookingUp, setIsLookingUp] = useState(true);
const [form, setForm] = useState({ password: '', confirm: '' });
const [errors, setErrors] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: settingsData } = usePublicSettings();
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = settingsData?.branding_logo_url?.trim();
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
// Pre-flight token validation. Same pattern as the invite page — if the
// token is invalid we render an error state instead of a useless form.
useEffect(() => {
let cancelled = false;
setIsLookingUp(true);
customerService.getPasswordReset(token)
.then((info) => {
if (cancelled) return;
setReset(info);
})
.catch(() => {
if (cancelled) return;
setLookupError(t(
'customer.resetPassword.invalidToken',
'This reset link is invalid or has expired. Please ask your photographer to send a new one.'
));
})
.finally(() => {
if (!cancelled) setIsLookingUp(false);
});
return () => { cancelled = true; };
}, [token, t]);
const validate = (): boolean => {
const next: Record<string, string> = {};
if (form.password.length < 8) {
next.password = t('customer.resetPassword.tooShort', 'Password must be at least 8 characters');
}
if (form.password !== form.confirm) {
next.confirm = t('customer.resetPassword.mismatch', 'Passwords do not match');
}
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setIsSubmitting(true);
try {
await customerService.applyPasswordReset(token, form.password);
toast.success(t('customer.resetPassword.successToast', 'Password updated. Please log in.'));
navigate('/customer/login?reset=1', { replace: true });
} catch (error: any) {
if (error.response?.data?.details?.length) {
setErrors({ password: error.response.data.details.join(' ') });
} else if (error.response?.status === 400) {
setErrors({ form: error.response?.data?.error || t('customer.resetPassword.invalidSubmission', 'Could not update your password.') });
} else {
toast.error(t('customer.resetPassword.generalError', 'Could not update your password. Please try again.'));
}
} finally {
setIsSubmitting(false);
}
};
return (
<div
className="customer-surface min-h-screen flex items-center justify-center px-4 py-8"
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
>
<div className="w-full max-w-md">
<div className="text-center mb-8">
<img
src={resolvedLogoUrl}
alt={companyName}
className="h-16 w-auto object-contain mx-auto mb-4"
/>
<h1 className="text-2xl font-bold text-theme">
{t('customer.resetPassword.title', 'Reset your password')}
</h1>
</div>
<Card padding="lg">
{isLookingUp ? (
<div className="flex justify-center py-8"><Loading size="lg" /></div>
) : lookupError || !reset ? (
<div className="flex items-start gap-2 text-sm">
<AlertCircle className="w-5 h-5 mt-0.5 flex-shrink-0 text-red-600" />
<p className="text-theme">{lookupError}</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex items-start gap-2 p-3 rounded-lg" style={{ backgroundColor: 'var(--color-elevated, #f5f5f5)' }}>
<CheckCircle className="w-5 h-5 mt-0.5 flex-shrink-0" style={{ color: 'var(--color-accent)' }} />
<div className="text-sm text-theme">
{t('customer.resetPassword.forEmail', 'Setting a new password for ')}
<span className="font-medium">{reset.email}</span>
{'.'}
</div>
</div>
{errors.form && (
<div role="alert" className="flex items-start gap-2 p-3 rounded-lg border" style={{ borderColor: 'var(--color-surface-border)' }}>
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-600" />
<span className="text-sm text-theme">{errors.form}</span>
</div>
)}
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.resetPassword.password', 'New password')}
</label>
<Input
type="password"
value={form.password}
onChange={(e) => setForm((p) => ({ ...p, password: e.target.value }))}
error={errors.password}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
autoComplete="new-password"
autoFocus
/>
<p className="mt-1 text-xs text-muted-theme">
{t('customer.resetPassword.hint', 'At least 8 characters with one uppercase letter and one number.')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customer.resetPassword.confirm', 'Confirm new password')}
</label>
<Input
type="password"
value={form.confirm}
onChange={(e) => setForm((p) => ({ ...p, confirm: e.target.value }))}
error={errors.confirm}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
autoComplete="new-password"
/>
</div>
<Button type="submit" variant="primary" size="lg" isLoading={isSubmitting} className="w-full">
{t('customer.resetPassword.submit', 'Update password')}
</Button>
</form>
)}
</Card>
</div>
</div>
);
};
export default CustomerResetPasswordPage;
+9
View File
@@ -0,0 +1,9 @@
export { CustomerLoginPage } from './CustomerLoginPage';
export { CustomerDashboardPage } from './CustomerDashboardPage';
export { CustomerAcceptInvitePage } from './CustomerAcceptInvitePage';
export { CustomerLayout } from './CustomerLayout';
export { CustomerProfilePage } from './CustomerProfilePage';
export { CustomerCalendarPage } from './CustomerCalendarPage';
export { CustomerQuotesPage } from './CustomerQuotesPage';
export { CustomerBillsPage } from './CustomerBillsPage';
export { CustomerResetPasswordPage } from './CustomerResetPasswordPage';
+224
View File
@@ -0,0 +1,224 @@
/**
* Customer-side API client (#354).
*
* Strictly separate from authService.adminLogin / galleryService — uses
* the /api/customer/* surface and the customer_token cookie. Never falls
* back to admin endpoints.
*/
import { api } from '../config/api';
export interface CustomerProfile {
id: number;
email: string;
displayName: string | null;
firstName: string | null;
lastName: string | null;
preferredLanguage: string;
}
/**
* Full self-service profile shape — superset of CustomerProfile (which is
* the narrow auth-payload version). Used by the profile page and the
* accept-invite form.
*/
export interface CustomerProfileFull extends CustomerProfile {
salutation: string | null;
phone: string | null;
companyName: string | null;
vatId: string | null;
addressLine1: string | null;
addressLine2: string | null;
postalCode: string | null;
city: string | null;
state: string | null;
countryCode: string | null;
}
/** Subset of profile fields the admin can pre-fill on an invitation
* and that the customer can edit on accept. */
export interface CustomerProfilePrefill {
salutation?: string;
first_name?: string;
last_name?: string;
display_name?: string;
phone?: string;
company_name?: string;
vat_id?: string;
address_line1?: string;
address_line2?: string;
postal_code?: string;
city?: string;
state?: string;
country_code?: string;
}
export interface CustomerEvent {
id: number;
slug: string;
eventName: string;
eventType: string;
eventDate: string | null;
expiresAt: string | null;
isActive: boolean;
assignedAt: string;
}
export interface CustomerInvitationInfo {
email: string;
expiresAt: string;
invitedBy: string | null;
/** Admin-supplied prefill — populates the accept-invite profile form. */
prefill: CustomerProfilePrefill | null;
}
export interface CustomerProfileUpdate {
salutation?: string | null;
firstName?: string | null;
lastName?: string | null;
displayName?: string | null;
phone?: string | null;
companyName?: string | null;
vatId?: string | null;
addressLine1?: string | null;
addressLine2?: string | null;
postalCode?: string | null;
city?: string | null;
state?: string | null;
countryCode?: string | null;
preferredLanguage?: string;
}
export interface CustomerAccessTokenResponse {
token: string;
event: { id: number; slug: string; eventName: string };
}
export const customerService = {
// ---- auth ----
async login(email: string, password: string, recaptchaToken?: string | null): Promise<{
customer: CustomerProfile;
features: { calendar: boolean; quotes: boolean; bills: boolean };
branding: { showLogo: boolean; showCompanyName: boolean };
}> {
const response = await api.post<{
customer: CustomerProfile;
features?: { calendar: boolean; quotes: boolean; bills: boolean };
branding?: { showLogo: boolean; showCompanyName: boolean };
}>(
'/customer/auth/login',
{ email, password, recaptchaToken }
);
// Backwards-compat fallbacks for older backends that haven't been
// upgraded yet — defaults match CustomerAuthContext's DEFAULT_*.
return {
customer: response.data.customer,
features: response.data.features || { calendar: false, quotes: false, bills: false },
branding: response.data.branding || { showLogo: true, showCompanyName: true },
};
},
async logout(): Promise<void> {
try {
await api.post('/customer/auth/logout');
} catch (e) {
// Logout is best-effort — the cookie clear is what matters and
// the backend always clears it even on error.
}
},
async session(): Promise<{
customer: CustomerProfile;
features: { calendar: boolean; quotes: boolean; bills: boolean };
branding: { showLogo: boolean; showCompanyName: boolean };
} | null> {
try {
const response = await api.get<{
customer: CustomerProfile;
features?: { calendar: boolean; quotes: boolean; bills: boolean };
branding?: { showLogo: boolean; showCompanyName: boolean };
}>('/customer/auth/session');
return {
customer: response.data.customer,
features: response.data.features || { calendar: false, quotes: false, bills: false },
branding: response.data.branding || { showLogo: true, showCompanyName: true },
};
} catch {
return null;
}
},
/**
* Look up a password-reset token without consuming it. Lets the reset
* page render "you're resetting the password for {{email}}" before
* the customer submits.
*/
async getPasswordReset(token: string): Promise<{ email: string; expiresAt: string }> {
const response = await api.get<{ reset: { email: string; expiresAt: string } }>(
`/customer/auth/password-reset/${encodeURIComponent(token)}`,
);
return response.data.reset;
},
/** Apply a password reset (token + new password). */
async applyPasswordReset(token: string, password: string): Promise<{ email: string }> {
const response = await api.post<{ email: string }>(
'/customer/auth/password-reset',
{ token, password },
);
return response.data;
},
// ---- invitations ----
async getInvitation(token: string): Promise<CustomerInvitationInfo> {
const response = await api.get<{ invitation: CustomerInvitationInfo }>(
`/customer/auth/invite/${encodeURIComponent(token)}`
);
return response.data.invitation;
},
async acceptInvitation(
token: string,
name: string,
password: string,
profile?: CustomerProfilePrefill,
): Promise<{ email: string }> {
const response = await api.post<{ email: string }>(
'/customer/auth/accept-invite',
{ token, name, password, profile },
);
return response.data;
},
// ---- profile (self-service) ----
async getProfile(): Promise<CustomerProfileFull> {
const response = await api.get<{ profile: CustomerProfileFull }>('/customer/profile');
return response.data.profile;
},
async updateProfile(payload: CustomerProfileUpdate): Promise<CustomerProfileFull> {
const response = await api.put<{ profile: CustomerProfileFull }>('/customer/profile', payload);
return response.data.profile;
},
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
await api.post('/customer/profile/password', { currentPassword, newPassword });
},
// ---- dashboard ----
async listEvents(): Promise<CustomerEvent[]> {
const response = await api.get<{ events: CustomerEvent[] }>('/customer/events');
return response.data.events;
},
/**
* Exchange the customer JWT for a gallery JWT scoped to one event.
* The dashboard calls this on card-click and stores the resulting
* token in the slug-specific gallery cookie via storeGalleryToken().
*/
async getEventAccessToken(slug: string): Promise<CustomerAccessTokenResponse> {
const response = await api.get<CustomerAccessTokenResponse>(
`/customer/events/${encodeURIComponent(slug)}/access-token`
);
return response.data;
},
};
@@ -0,0 +1,188 @@
/**
* Admin → Customers API client (#354).
*
* Hits /api/admin/customers/* (admin auth). Distinct from customer.service.ts
* which is the customer's own /api/customer/* surface.
*/
import { api } from '../config/api';
export interface CustomerAccountSummary {
id: number;
email: string;
displayName: string | null;
firstName: string | null;
lastName: string | null;
salutation: string | null;
companyName: string | null;
isActive: boolean;
lastLogin: string | null;
createdAt: string;
eventCount?: number;
/** Per-customer feature flags (#354 follow-up). */
featureCalendar?: boolean;
featureQuotes?: boolean;
featureBills?: boolean;
}
export interface CustomerAccountDetail extends CustomerAccountSummary {
phone: string | null;
billingEmail: string | null;
vatId: string | null;
addressLine1: string | null;
addressLine2: string | null;
postalCode: string | null;
city: string | null;
state: string | null;
countryCode: string | null;
preferredLanguage: string;
notes: string | null;
events: Array<{
id: number;
slug: string;
eventName: string;
eventDate: string | null;
expiresAt: string | null;
isArchived: boolean;
assignedAt: string;
}>;
}
/** Optional admin-side prefill on invite — see /admin/customers/invite. */
export interface CustomerInvitePrefill {
salutation?: string;
first_name?: string;
last_name?: string;
display_name?: string;
phone?: string;
company_name?: string;
vat_id?: string;
address_line1?: string;
address_line2?: string;
postal_code?: string;
city?: string;
state?: string;
country_code?: string;
}
export interface CustomerInvitationSummary {
id: number;
email: string;
expiresAt: string;
createdAt: string;
invitedBy: string | null;
}
export const customerAdminService = {
async list(search?: string): Promise<CustomerAccountSummary[]> {
const response = await api.get<{ customers: CustomerAccountSummary[] }>(
'/admin/customers',
{ params: search ? { search } : undefined }
);
return response.data.customers;
},
async search(term: string): Promise<CustomerAccountSummary[]> {
if (!term || !term.trim()) return [];
const response = await api.get<{ customers: CustomerAccountSummary[] }>(
'/admin/customers/search',
{ params: { email: term } }
);
return response.data.customers;
},
async get(id: number): Promise<CustomerAccountDetail> {
const response = await api.get<{ customer: CustomerAccountDetail }>(`/admin/customers/${id}`);
return response.data.customer;
},
async update(id: number, payload: Partial<Omit<CustomerAccountDetail, 'id' | 'events' | 'eventCount'>>): Promise<CustomerAccountDetail> {
// Frontend sends camelCase, backend accepts snake_case — translate here
// so callers can stay in TS-land conventions.
const snake: Record<string, any> = {};
const map: Record<string, string> = {
email: 'email',
salutation: 'salutation',
firstName: 'first_name',
lastName: 'last_name',
displayName: 'display_name',
phone: 'phone',
companyName: 'company_name',
billingEmail: 'billing_email',
vatId: 'vat_id',
addressLine1: 'address_line1',
addressLine2: 'address_line2',
postalCode: 'postal_code',
city: 'city',
state: 'state',
countryCode: 'country_code',
preferredLanguage: 'preferred_language',
notes: 'notes',
isActive: 'is_active',
// Per-customer feature flags (#354 follow-up).
featureCalendar: 'feature_calendar',
featureQuotes: 'feature_quotes',
featureBills: 'feature_bills',
};
for (const [k, v] of Object.entries(payload)) {
if (k in map) snake[map[k]] = v;
}
const response = await api.put<{ customer: CustomerAccountDetail }>(`/admin/customers/${id}`, snake);
return response.data.customer;
},
async deactivate(id: number): Promise<void> {
await api.post(`/admin/customers/${id}/deactivate`);
},
/** Restore a deactivated customer (login re-enabled, assignments stay). */
async reactivate(id: number): Promise<void> {
await api.post(`/admin/customers/${id}/reactivate`);
},
/**
* Anonymize-in-place erasure (GDPR style). Customer row stays for
* audit FKs but every PII column is nulled and credentials are wiped.
* See backend service `eraseCustomer` for the full contract.
*/
async erase(id: number): Promise<void> {
await api.post(`/admin/customers/${id}/erase`);
},
/**
* Trigger a password reset for an existing customer. The backend
* generates a 7-day single-use token and emails the customer.
*/
async sendPasswordReset(id: number): Promise<{ email: string; expiresAt: string }> {
const response = await api.post<{ data: { email: string; expiresAt: string } } | { email: string; expiresAt: string }>(
`/admin/customers/${id}/password-reset`,
);
return ((response.data as any).data ?? response.data) as { email: string; expiresAt: string };
},
/**
* Invite a customer. `prefill` is an optional set of profile fields the
* admin can pre-populate on the invitation row — the customer sees them
* pre-filled (and editable) on the accept form. Saves the customer typing
* for the common case where the photographer already has the wedding
* couple's name + address from the booking form.
*/
async invite(
email: string,
prefill?: CustomerInvitePrefill,
): Promise<{ id: number; email: string; expiresAt: string }> {
const response = await api.post<{ data: { invitation: { id: number; email: string; expiresAt: string } } }>(
'/admin/customers/invite',
{ email, prefill },
);
return (response.data as any).data?.invitation ?? (response.data as any).invitation;
},
async listInvitations(): Promise<CustomerInvitationSummary[]> {
const response = await api.get<{ invitations: CustomerInvitationSummary[] }>('/admin/customers/invitations');
return response.data.invitations;
},
async cancelInvitation(id: number): Promise<void> {
await api.delete(`/admin/customers/invitations/${id}`);
},
};
+7
View File
@@ -41,6 +41,10 @@ interface CreateEventData {
show_feedback_to_guests?: boolean;
photo_cap?: number | null;
default_photo_sort?: string;
// Customer accounts assigned to this event (#354). Optional array of
// customer_accounts.id; backend service diffs against the existing
// assignments and applies inserts/deletes inside the same transaction.
customer_account_ids?: number[];
}
interface UpdateEventData {
@@ -62,6 +66,9 @@ interface UpdateEventData {
external_path?: string | null;
photo_cap?: number | null;
default_photo_sort?: string;
// Customer accounts (#354). Same semantics as on CreateEventData;
// omit the field to leave assignments untouched, send [] to clear.
customer_account_ids?: number[];
}
export type EventStatusFilter = 'active' | 'inactive' | 'archived' | 'draft' | 'expiring';
@@ -9,7 +9,14 @@ export type FeatureKey =
| 'bills'
| 'messaging'
| 'analytics'
| 'userManagement';
| 'userManagement'
// Foundation flag for the customer-side surface (#354). Gates the
// /customer/* routes (login, dashboard, profile, accept-invite,
// reset-password) and the admin Customers management page. The
// calendar / calendarBooking / quotes / bills / messaging flags
// above hang off this — they only appear in the customer dashboard
// when customerPortal is also ON.
| 'customerPortal';
export type FeatureFlags = Record<FeatureKey, boolean>;
+2 -1
View File
@@ -9,4 +9,5 @@ export { settingsService } from './settings.service';
export { cmsService } from './cms.service';
export { notificationsService } from './notifications.service';
export { feedbackService } from './feedback.service';
export { userManagementService } from './userManagement.service';
export { userManagementService } from './userManagement.service';
export { customerService } from './customer.service';