Merge pull request #603 from Luca-Timo/feat/crm-improvements

CRM improvements: invoicing & payments, hours, email queue/scheduling, branding (dark mode + favicon), country pickers
This commit is contained in:
Paul Nothaft
2026-06-04 22:09:08 +02:00
committed by GitHub
104 changed files with 4685 additions and 629 deletions
@@ -35,7 +35,11 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const currentLanguage = SUPPORTED_LANGUAGES.find(lang => lang.code === i18n.language) || SUPPORTED_LANGUAGES[0];
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = brandingSettings?.branding_logo_url?.trim();
// Dark-mode logo variant. Symmetric fallback: if only one logo is set,
// use it for both modes (dark → dark||light, light → light||dark).
const lightLogo = brandingSettings?.branding_logo_url?.trim();
const darkLogo = brandingSettings?.branding_logo_url_dark?.trim();
const logoUrl = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text';
// Logo placement honours the same Branding > Logo Position setting
// the gallery does. 'sidepanel' moves the logo into the AdminSidebar
@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'react-toastify';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -10,6 +9,7 @@ import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../..
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
type AdminFeedbackResponse = {
feedback: PhotoFeedback[];
@@ -38,6 +38,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
const [showCategoryMenu, setShowCategoryMenu] = useState(false);
const [expandedComments, setExpandedComments] = useState(false);
const queryClient = useQueryClient();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const currentPhoto = photos[currentIndex];
const isVideo = currentPhoto
@@ -312,7 +313,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
Uploaded
</span>
<p className="text-white">
{format(new Date(currentPhoto.uploaded_at), 'MMM d, yyyy h:mm a')}
{fmtDateTime(currentPhoto.uploaded_at)}
</p>
</div>
@@ -408,7 +409,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
{comment.guest_name || 'Anonymous'}
</p>
<p className="text-xs text-neutral-400">
{format(new Date(comment.created_at), 'MMM d, yyyy h:mm a')}
{fmtDateTime(comment.created_at)}
</p>
</div>
@@ -6,6 +6,7 @@ import {
Archive,
BarChart3,
Settings,
Activity,
X,
Users,
Briefcase,
@@ -17,6 +18,7 @@ import { useTranslation } from 'react-i18next';
import { settingsService } from '../../services/settings.service';
import { VersionInfo } from './VersionInfo';
import { usePermissions } from '../../contexts/PermissionsContext';
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { buildResourceUrl } from '../../utils/url';
@@ -62,6 +64,7 @@ const navigation: NavItem[] = [
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' },
{ 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.systemHealth', href: '/admin/system-health', icon: Activity, permission: 'settings.view' },
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view', featureFlag: 'userManagement' },
// Clients section (#354 follow-up) — admin-side surface for the
// CRM-area sub-features. Today this entry leads to /admin/clients
@@ -104,8 +107,12 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, col
// chosen, the logo replaces the "PicPeak Admin" text in the brand
// row, and the favicon takes over in the collapsed icon rail.
const { data: publicSettings } = usePublicSettings();
const { isDark } = useAdminDarkMode();
const logoInSidebar = publicSettings?.branding_logo_position === 'sidepanel';
const rawLogoUrl = publicSettings?.branding_logo_url?.trim();
// Theme-aware logo with symmetric fallback (one logo serves both modes).
const lightLogo = publicSettings?.branding_logo_url?.trim();
const darkLogo = publicSettings?.branding_logo_url_dark?.trim();
const rawLogoUrl = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
const rawFaviconUrl = publicSettings?.branding_favicon_url?.trim();
const resolvedLogoUrl = rawLogoUrl
? (rawLogoUrl.startsWith('http') ? rawLogoUrl : buildResourceUrl(rawLogoUrl))
@@ -8,7 +8,6 @@ import {
CheckCircle,
User
} from 'lucide-react';
import { parseISO } from 'date-fns';
import { toast } from 'react-toastify';
import { Card, Loading, Button } from '../common';
@@ -30,7 +29,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
maxItems = 5
}) => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { formatDateTime } = useLocalizedDate();
const queryClient = useQueryClient();
const [showAll, setShowAll] = useState(false);
@@ -116,12 +115,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
</span>
<span className="text-neutral-500 dark:text-neutral-400"></span>
<span className="text-neutral-500 dark:text-neutral-400">
{format(
typeof item.created_at === 'string'
? parseISO(item.created_at)
: new Date(item.created_at),
'MMM d, h:mm a'
)}
{formatDateTime(item.created_at)}
</span>
</div>
<p className="mt-1 text-sm text-neutral-700">{item.comment_text || item.comment}</p>
@@ -8,6 +8,7 @@ interface GalleryPreviewBranding {
company_name?: string;
company_tagline?: string;
logo_url?: string;
logo_url_dark?: string;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
logo_position?: 'left' | 'center' | 'right';
}
@@ -82,10 +83,13 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
const showText = displayMode === 'text_only' || displayMode === 'logo_and_text';
const brandName = branding?.company_name?.trim() || 'Your Studio';
const brandTagline = branding?.company_tagline?.trim() || '';
const resolvedLogoUrl = showLogo && branding?.logo_url
? (branding.logo_url.startsWith('http')
? branding.logo_url
: buildResourceUrl(branding.logo_url))
// Theme-aware logo with symmetric fallback — mirror the live surfaces
// so the preview reflects what the gallery will actually show.
const previewLogo = theme.colorMode === 'dark'
? (branding?.logo_url_dark || branding?.logo_url)
: (branding?.logo_url || branding?.logo_url_dark);
const resolvedLogoUrl = showLogo && previewLogo
? (previewLogo.startsWith('http') ? previewLogo : buildResourceUrl(previewLogo))
: null;
const logoPosition = branding?.logo_position || 'left';
const brandFlexClass = logoPosition === 'center'
+113 -47
View File
@@ -15,9 +15,10 @@
import React, { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import { Clock } from 'lucide-react';
import { Button, Card } from '../common';
import { Clock, AlertTriangle } from 'lucide-react';
import { Button, Card, LocalizedDateInput, TimeField } from '../common';
import { DecimalInput } from '../common/DecimalInput';
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
import { customerAdminService } from '../../services/customerAdmin.service';
@@ -45,13 +46,8 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
}) => {
const { t } = useTranslation();
const qc = useQueryClient();
const { format: fmtDate, formatTime: fmtTime, timeFormat } = useLocalizedDate();
// `lang` hint on <input type="time"> nudges Chrome/Edge to render the
// picker in the matching clock convention (de-DE → 24h, en-US → 12h).
// Safari/Firefox follow OS locale and ignore this — that's a browser
// limitation, not something we can fix in the page. The underlying
// value stays HH:mm (24h) regardless of how the picker presents it.
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
const navigate = useNavigate();
const { format: fmtDate, formatTime: fmtTime } = useLocalizedDate();
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10));
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00');
@@ -91,6 +87,22 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
staleTime: 5 * 60 * 1000,
});
const profileDefaultCurrency = profileSnapshot?.profile?.defaultCurrency || 'CHF';
// Install-wide fallback rate (migration 113). Last link in the rate
// chain after the per-entry override and the per-customer default.
const installDefaultRateMinor = profileSnapshot?.profile?.defaultHourlyRateMinor ?? null;
// The rate that applies to a NEW entry when no per-entry override is
// typed: customer rate, else the install default. null = neither set,
// so a save would fail unless the admin enters an override.
const effectiveDefaultRateMinor = customerHourlyRateMinor ?? installDefaultRateMinor;
// True when there's genuinely no rate to bill at — drives the inline
// CTA + disables the save button. An override typed in the form lifts
// this (handled below where the button is rendered).
const noRateConfigured = effectiveDefaultRateMinor == null;
const overrideTyped = (() => {
if (!rateOverride.trim()) return false;
const n = parseLocaleDecimal(rateOverride);
return Number.isFinite(n) && n >= 0;
})();
const createMutation = useMutation({
mutationFn: () => customerAdminService.createHourEntry(customerId, {
@@ -114,7 +126,17 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
toast.success(t('customers.hours.toast.created', 'Entry logged'));
},
onError: (err: any) => {
const msg = err?.response?.data?.error || err?.message || 'Failed to log entry';
// The save-time "no rate" failure is translated here off the
// backend error code (the raw message is English-only). The inline
// guard below normally prevents this, but a race (rate cleared in
// another tab) can still surface it.
if (err?.response?.data?.code === 'HOURLY_RATE_REQUIRED') {
toast.error(t('customers.hours.error.noRate',
'No hourly rate set for this customer. Enter a rate override, set a rate on the customer, or configure an install-wide default in Settings.'));
return;
}
const msg = err?.response?.data?.error || err?.message
|| t('customers.hours.error.createFailed', 'Failed to log entry');
toast.error(msg);
},
});
@@ -133,10 +155,13 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
const billMutation = useMutation({
mutationFn: () => customerAdminService.billUnbilledHourEntries(customerId),
onSuccess: () => {
onSuccess: ({ invoiceId }) => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
toast.success(t('customers.hours.toast.billed', 'Hours billed'));
// Open the new scheduled invoice so the admin can add other line
// items in addition to the hours before it ships.
if (invoiceId) navigate(`/admin/clients/bills/${invoiceId}/edit`);
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || 'Failed to bill hours');
@@ -153,11 +178,11 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
for (const e of entries) {
if (e.status !== 'unbilled') continue;
count += 1;
const rateMinor = e.hourlyRateMinorOverride ?? customerHourlyRateMinor ?? 0;
const rateMinor = e.hourlyRateMinorOverride ?? effectiveDefaultRateMinor ?? 0;
minor += rateMinor * e.durationMinutes / 60;
}
return { unbilledCount: count, unbilledTotalMajor: minor / 100 };
}, [entries, customerHourlyRateMinor]);
}, [entries, effectiveDefaultRateMinor]);
const isMonthly = billingCadence === 'monthly';
// Local lockout check — mirrors customerHoursService.isEntryLocked
@@ -181,36 +206,75 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
? t('customers.hours.monthlyHint',
'Entries auto-append to the current monthly draft. Edit / delete remains possible until the scheduler arms the draft for send.')
: t('customers.hours.perEventHint',
'Logged entries stay unbilled until you click "Create draft invoice" — a standalone draft invoice is generated with one line per entry, ready for you to review before sending.')}
'Logged entries stay unbilled until you click "Create draft invoice" — one scheduled invoice is generated with a line per entry and opened in the editor, so you can add other items before it ships.')}
</p>
{/* Default rate — hidden in compact mode (history-only on the
customer detail page; admin edits the rate elsewhere). */}
{/* Rate summary — hidden in compact mode (history-only on the
customer detail page). When a caller wires onHourlyRateChange
the field is editable; otherwise (the standalone hours page)
we show the RESOLVED rate read-only so a disabled input can't
masquerade as an editable value, and surface a CTA when no rate
is configured anywhere along the chain. */}
{!compact && (
<div className="mb-4">
<label className="block text-sm font-medium text-theme mb-1">
{t('customers.field.hourlyRate', 'Default hourly rate')}
</label>
<DecimalInput
value={customerHourlyRateMinor != null ? customerHourlyRateMinor / 100 : NaN}
fractionDigits={2}
onChange={(n) => {
if (!onHourlyRateChange) return;
if (!Number.isFinite(n)) {
onHourlyRateChange(null);
return;
}
onHourlyRateChange(Math.max(0, Math.round(n * 100)));
}}
disabled={!onHourlyRateChange}
className="w-40 input"
placeholder="150.00"
/>
<p className="text-xs text-muted-theme mt-1">
{t('customers.field.hourlyRateHint',
'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.',
{ currency: profileDefaultCurrency })}
</p>
{onHourlyRateChange ? (
<>
<DecimalInput
value={customerHourlyRateMinor != null ? customerHourlyRateMinor / 100 : NaN}
fractionDigits={2}
onChange={(n) => {
if (!Number.isFinite(n)) { onHourlyRateChange(null); return; }
onHourlyRateChange(Math.max(0, Math.round(n * 100)));
}}
className="w-40 input"
placeholder="150.00"
/>
<p className="text-xs text-muted-theme mt-1">
{t('customers.field.hourlyRateHint',
'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.',
{ currency: profileDefaultCurrency })}
</p>
</>
) : noRateConfigured ? (
<div className="rounded-md border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm">
<div className="flex items-start gap-2 text-amber-800 dark:text-amber-200">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<p className="font-medium">
{t('customers.hours.noRate.title', 'No hourly rate configured')}
</p>
<p className="mt-0.5 text-amber-700 dark:text-amber-300">
{t('customers.hours.noRate.body',
'Logging needs a rate. Set one for this customer, type a per-entry override below, or configure an install-wide default.')}
</p>
<div className="mt-2 flex flex-wrap gap-3">
<Link to={`/admin/clients/accounts/${customerId}`}
className="text-accent-dark hover:underline font-medium">
{t('customers.hours.noRate.setForCustomer', 'Set a rate for this customer')}
</Link>
<Link to="/admin/settings?tab=businessProfile" target="_blank" rel="noopener noreferrer"
className="text-accent-dark hover:underline font-medium">
{t('customers.hours.noRate.setInstallDefault', 'Set an install-wide default')}
</Link>
</div>
</div>
</div>
</div>
) : (
<p className="text-sm text-theme">
<span className="tabular-nums font-medium">
{profileDefaultCurrency} {((effectiveDefaultRateMinor as number) / 100).toFixed(2)}
</span>
<span className="text-xs text-muted-theme ml-2">
{customerHourlyRateMinor != null
? t('customers.hours.rateSource.customer', 'from this customer')
: t('customers.hours.rateSource.installDefault', 'install-wide default')}
</span>
</p>
)}
</div>
)}
@@ -224,22 +288,19 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.date', 'Date')}
</label>
<input type="date" value={entryDate}
onChange={(e) => setEntryDate(e.target.value)} className="input w-full" />
<LocalizedDateInput value={entryDate} onChange={setEntryDate} />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.start', 'Start')}
</label>
<input type="time" lang={timeInputLang} value={startTime}
onChange={(e) => setStartTime(e.target.value)} className="input w-full" />
<TimeField value={startTime} onChange={setStartTime} />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.end', 'End')}
</label>
<input type="time" lang={timeInputLang} value={endTime}
onChange={(e) => setEndTime(e.target.value)} className="input w-full" />
<TimeField value={endTime} onChange={setEndTime} />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
@@ -271,8 +332,8 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
inputMode="decimal"
value={rateOverride}
onChange={(e) => setRateOverride(e.target.value)}
placeholder={customerHourlyRateMinor != null
? (customerHourlyRateMinor / 100).toFixed(2)
placeholder={effectiveDefaultRateMinor != null
? (effectiveDefaultRateMinor / 100).toFixed(2)
: '—'}
className="input w-full" />
</div>
@@ -287,10 +348,15 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
placeholder={t('customers.hours.form.notePlaceholder',
'What was worked on?') as string} />
</div>
<div className="mt-3 flex justify-end">
<div className="mt-3 flex items-center justify-end gap-3">
{noRateConfigured && !overrideTyped && (
<span className="text-xs text-amber-700 dark:text-amber-300">
{t('customers.hours.form.needRate', 'Set a rate or enter an override to log time.')}
</span>
)}
<Button
variant="primary"
disabled={createMutation.isPending}
disabled={createMutation.isPending || (noRateConfigured && !overrideTyped)}
isLoading={createMutation.isPending}
onClick={() => createMutation.mutate()}
>
@@ -348,7 +414,7 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
</thead>
<tbody>
{entries.map((e) => {
const rate = e.hourlyRateMinorOverride ?? customerHourlyRateMinor ?? 0;
const rate = e.hourlyRateMinorOverride ?? effectiveDefaultRateMinor ?? 0;
const hours = e.durationMinutes / 60;
const total = (hours * rate) / 100;
const locked = isLocked(e);
@@ -23,7 +23,7 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save, Send, X } from 'lucide-react';
import { Button, Input } from '../common';
import { Button, CountrySelect, Input } from '../common';
import {
customerAdminService,
type CustomerAccountDetail,
@@ -136,28 +136,42 @@ export const InlineCustomerCreate: React.FC<Props> = ({ onCreated, onCancel, mod
staleTime: 5 * 60 * 1000,
});
const profileDefaultLocale = profileSnapshot?.profile?.defaultLocale || 'en';
const profileCountryCode = profileSnapshot?.profile?.countryCode || '';
// Seed preferredLanguage with the profile default once the profile
// arrives (only if the field is still empty so we don't clobber
// explicit user input).
// Seed preferredLanguage + countryCode with the profile defaults once
// the profile arrives (only if the field is still empty so we don't
// clobber explicit user input).
React.useEffect(() => {
if (profileDefaultLocale && !form.preferredLanguage) {
setForm((prev) => prev.preferredLanguage ? prev : { ...prev, preferredLanguage: profileDefaultLocale });
}
setForm((prev) => {
const next = { ...prev };
if (profileDefaultLocale && !prev.preferredLanguage) next.preferredLanguage = profileDefaultLocale;
if (profileCountryCode && !prev.countryCode) next.countryCode = profileCountryCode.toUpperCase();
return next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profileDefaultLocale]);
}, [profileDefaultLocale, profileCountryCode]);
const setField = (key: keyof FormState) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm((prev) => ({ ...prev, [key]: e.target.value }));
const isValid = !!form.email && /\S+@\S+\.\S+/.test(form.email);
const hasEmail = !!form.email && /\S+@\S+\.\S+/.test(form.email);
// At least one human-readable identifier so the record isn't a
// nameless row that's impossible to recognise in lists later.
const hasName = !!(form.companyName.trim() || form.displayName.trim()
|| form.firstName.trim() || form.lastName.trim());
const isValid = hasEmail && hasName;
const handleSave = async (mode: 'passive' | 'invite') => {
if (!isValid) {
if (!hasEmail) {
toast.error(t('customers.create.emailRequired', 'A valid email is required.'));
return;
}
if (!hasName) {
toast.error(t('customers.create.nameRequired',
'Enter at least a company name or a contact name.'));
return;
}
setBusy(mode);
try {
const customer = await customerAdminService.createDirect(form.email, buildPrefill(form));
@@ -298,12 +312,10 @@ export const InlineCustomerCreate: React.FC<Props> = ({ onCreated, onCancel, mod
value={form.state}
onChange={setField('state')}
/>
<Input
label={t('customers.detail.countryCode', 'Country (ISO code)') as string}
<CountrySelect
label={t('customers.detail.country', 'Country') as string}
value={form.countryCode}
onChange={setField('countryCode')}
placeholder="CH"
maxLength={2}
onChange={(code) => setForm((prev) => ({ ...prev, countryCode: code }))}
/>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
@@ -24,14 +24,15 @@ import {
AlertCircle,
ShieldCheck
} from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'react-toastify';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Button, Card, Input, Loading } from '../common';
import { api } from '../../config/api';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
export const RestoreWizard = ({ onVerifyIntegrity } = {}) => {
const { t } = useTranslation();
const { format: fmtDate, formatTime: fmtTime, formatDateTime: fmtDateTime } = useLocalizedDate();
const [currentStep, setCurrentStep] = useState(0);
const steps = [
@@ -331,7 +332,7 @@ export const RestoreWizard = ({ onVerifyIntegrity } = {}) => {
</div>
<div>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{format(new Date(backup.created_at), 'PPP')} {t('backup.restore.backup.at')} {format(new Date(backup.created_at), 'p')}
{fmtDate(backup.created_at)} {t('backup.restore.backup.at')} {fmtTime(backup.created_at)}
</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('backup.dashboard.backupType', { type: backup.backup_type })} {formatBytes(backup.total_size || 0)}
@@ -586,7 +587,7 @@ export const RestoreWizard = ({ onVerifyIntegrity } = {}) => {
<div className="flex justify-between">
<dt className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.summary.backupDate')}:</dt>
<dd className="font-medium text-neutral-900 dark:text-neutral-100">
{format(new Date(restoreData.selectedBackup.created_at), 'PPp')}
{fmtDateTime(restoreData.selectedBackup.created_at)}
</dd>
</div>
<div className="flex justify-between">
@@ -0,0 +1,172 @@
/**
* Sent-emails feed — read-only, paginated view of the email_queue table.
* Rendered as the "Sent emails" tab inside EmailConfigPage. Pairs with
* the "Send queued emails now" flush button on the SMTP tab: flush, then
* watch what sent / failed here.
*
* Filters: status (pending/sent/failed), free-text search (recipient or
* type), and a created-at date range. email_data is never fetched.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Search, AlertCircle } from 'lucide-react';
import { Button, Card, Loading } from '../common';
import { LocalizedDateInput } from '../common/LocalizedDateInput';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { emailService, type EmailQueueStatus } from '../../services/email.service';
const STATUSES: EmailQueueStatus[] = ['pending', 'sent', 'failed'];
const statusClass = (s: EmailQueueStatus): string =>
s === 'sent' ? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
: s === 'failed' ? 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300';
export const SentEmailsPanel: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<EmailQueueStatus | null>(null);
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['email-queue', { search, statusFilter, from, to, page }],
queryFn: () => emailService.listQueue({
q: search || undefined,
status: statusFilter || undefined,
from: from || undefined,
to: to || undefined,
page,
pageSize: 25,
}),
});
const resetTo1 = () => setPage(1);
return (
<Card padding="lg">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
{t('email.sentEmails.title', 'Sent emails')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('email.sentEmails.subtitle', 'Delivery status of every queued and sent notification.')}
</p>
<div className="flex flex-wrap items-end gap-3">
<div className="relative flex-1 min-w-[220px]">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" />
<input
type="text"
placeholder={t('email.sentEmails.searchPlaceholder', 'Search by recipient or type…') as string}
className="w-full pl-9 pr-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); resetTo1(); }}
/>
</div>
<div className="w-40">
<LocalizedDateInput label={t('email.sentEmails.from', 'From') as string} value={from}
onChange={(iso) => { setFrom(iso); resetTo1(); }} />
</div>
<div className="w-40">
<LocalizedDateInput label={t('email.sentEmails.to', 'To') as string} value={to}
onChange={(iso) => { setTo(iso); resetTo1(); }} />
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1">
{STATUSES.map((s) => {
const active = statusFilter === s;
return (
<button key={s} type="button"
onClick={() => { setStatusFilter(active ? null : s); resetTo1(); }}
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-colors ${
active
? 'bg-accent-dark text-white border-accent-dark'
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600'
}`}
>{t(`email.sentEmails.status.${s}`, s)}</button>
);
})}
</div>
<div className="mt-4">
{isLoading ? <Loading /> : !data || data.items.length === 0 ? (
<p className="text-center text-neutral-500 dark:text-neutral-400 py-8">
{t('email.sentEmails.empty', 'No emails match these filters.')}
</p>
) : (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.recipient', 'Recipient')}</th>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.type', 'Type')}</th>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.status', 'Status')}</th>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.created', 'Queued')}</th>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.sent', 'Sent')}</th>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.event', 'Event')}</th>
</tr>
</thead>
<tbody>
{data.items.map((m) => (
<tr key={m.id} className="border-t border-neutral-200 dark:border-neutral-700 align-top">
<td className="px-3 py-2 break-all">{m.recipientEmail}</td>
<td className="px-3 py-2 font-mono text-xs">{m.emailType}</td>
<td className="px-3 py-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusClass(m.status)}`}>
{t(`email.sentEmails.status.${m.status}`, m.status)}
</span>
{m.status === 'failed' && m.errorMessage && (
<div className="mt-1 flex items-start gap-1 text-xs text-red-700 dark:text-red-400 max-w-xs">
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span className="break-words">{m.errorMessage}</span>
</div>
)}
{m.status === 'pending' && m.retryCount > 0 && (
<div className="mt-1 text-xs text-amber-700 dark:text-amber-400">
{t('email.sentEmails.retries', '{{count}} retries', { count: m.retryCount })}
</div>
)}
</td>
<td className="px-3 py-2 whitespace-nowrap">{m.createdAt ? fmtDateTime(m.createdAt) : '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{m.sentAt ? fmtDateTime(m.sentAt) : '—'}</td>
<td className="px-3 py-2">
{m.eventId ? (
<Link to={`/admin/events/${m.eventId}`} className="text-accent hover:underline" onClick={(e) => e.stopPropagation()}>
{m.eventName || `#${m.eventId}`}
</Link>
) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{data.pagination.totalPages > 1 && (
<div className="flex justify-between items-center px-3 py-2 border-t border-neutral-200 dark:border-neutral-700 text-sm">
<span className="text-neutral-500 dark:text-neutral-400">
{t('email.sentEmails.pagination', 'Page {{page}} of {{total}} · {{count}} emails', {
page: data.pagination.page, total: data.pagination.totalPages, count: data.pagination.total,
})}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('common.previous', 'Previous')}
</Button>
<Button variant="outline" size="sm" disabled={page >= data.pagination.totalPages} onClick={() => setPage((p) => p + 1)}>
{t('common.next', 'Next')}
</Button>
</div>
</div>
)}
</div>
)}
</div>
</Card>
);
};
@@ -0,0 +1,77 @@
import React from 'react';
import { clsx } from 'clsx';
import { useTranslation } from 'react-i18next';
import { countryLabel, sortedCountryOptions } from '../../constants/countries';
/**
* Country picker whose option labels are localized country names but
* whose stored/emitted value is always the ISO 3166-1 alpha-2 code.
* Labels come from `Intl.DisplayNames` in the active UI language, so the
* list stays locale-aware without a hand-maintained translation map.
*
* A value that isn't in the curated list (e.g. legacy data) is preserved
* as its own option so editing an existing record never silently drops it.
*/
interface CountrySelectProps {
label?: string;
value: string;
onChange: (code: string) => void;
error?: string;
disabled?: boolean;
/** Label for the empty option; defaults to a translated placeholder. */
placeholder?: string;
}
export const CountrySelect: React.FC<CountrySelectProps> = ({
label,
value,
onChange,
error,
disabled,
placeholder,
}) => {
const { i18n, t } = useTranslation();
const lang = i18n.language || 'en';
const selectId = React.useId();
const options = sortedCountryOptions(lang);
const current = (value || '').trim().toUpperCase();
const hasCurrent = options.some((o) => o.code === current);
return (
<div className="w-full">
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1.5"
>
{label}
</label>
)}
<select
id={selectId}
value={current}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
className={clsx('input', error && 'border-red-500 focus-visible:ring-red-500')}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? `${selectId}-error` : undefined}
>
<option value="">{placeholder ?? t('common.selectCountry', 'Select country…')}</option>
{!hasCurrent && current && (
<option value={current}>{countryLabel(current, lang)}</option>
)}
{options.map((o) => (
<option key={o.code} value={o.code}>
{o.label}
</option>
))}
</select>
{error && (
<p id={`${selectId}-error`} className="mt-1.5 text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
</div>
);
};
@@ -14,15 +14,37 @@ export const DynamicFavicon: React.FC = () => {
const existingFavicons = document.querySelectorAll("link[rel*='icon']");
existingFavicons.forEach(favicon => favicon.remove());
// Create new favicon link
const link = document.createElement('link');
link.rel = 'icon';
link.type = 'image/png';
link.href = settings.branding_favicon_url.startsWith('http')
// Create new favicon link. Derive the MIME type from the file
// extension — hardcoding image/png made SVG (and .ico) favicons
// get declared as PNG, which browsers reject (favicon didn't show).
const href = settings.branding_favicon_url.startsWith('http')
? settings.branding_favicon_url
: buildResourceUrl(settings.branding_favicon_url);
const ext = href.split('?')[0].split('.').pop()?.toLowerCase();
const typeByExt: Record<string, string> = {
svg: 'image/svg+xml',
png: 'image/png',
ico: 'image/x-icon',
gif: 'image/gif',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
webp: 'image/webp',
};
const link = document.createElement('link');
link.rel = 'icon';
if (ext && typeByExt[ext]) link.type = typeByExt[ext];
link.href = href;
document.head.appendChild(link);
// Safari uses apple-touch-icon for bookmarks / home-screen and is
// unreliable about JS-injected rel="icon". The backend /favicon.ico +
// /apple-touch-icon routes are the primary mechanism; this is
// belt-and-braces for browsers that do read the DOM link.
const appleLink = document.createElement('link');
appleLink.rel = 'apple-touch-icon';
appleLink.href = href;
document.head.appendChild(appleLink);
}
}, [settings?.branding_favicon_url]);
@@ -0,0 +1,187 @@
import React from 'react';
import { clsx } from 'clsx';
import { Calendar } from 'lucide-react';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
/**
* Date input that displays + accepts values in the admin-configured
* format from Settings → General (`general_date_format`), independent
* of the browser locale. Stores + emits ISO (YYYY-MM-DD) so the rest
* of the form / API surface keeps the canonical shape.
*
* A native `<input type="date">` always renders in the browser's own
* locale (en-US users see MM/DD/YYYY) no matter what the app is
* configured for, so it can't be used directly. This component shows
* a plain text input in the configured format and parses on blur. A
* calendar icon button opens the native date picker (via showPicker())
* off a visually-hidden native input, giving the click-to-pick
* affordance without rendering a second visible date box.
*/
interface LocalizedDateInputProps {
label?: string;
value: string;
onChange: (iso: string) => void;
error?: string;
/** Forwarded to the native picker so min/max date constraints work. */
min?: string;
max?: string;
disabled?: boolean;
}
export const LocalizedDateInput: React.FC<LocalizedDateInputProps> = ({
label,
value,
onChange,
error,
min,
max,
disabled,
}) => {
const { dateFormat } = useLocalizedDate();
const nativeRef = React.useRef<HTMLInputElement>(null);
const inputId = React.useId();
// Normalise the configured format down to the four shapes the parser
// understands. Defaults to DD.MM.YYYY (the operator's primary locale)
// when unknown.
const normalisedFormat = ((): 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD' => {
const f = String(dateFormat || 'dd.MM.yyyy').toLowerCase();
if (f.startsWith('mm/dd')) return 'MM/DD/YYYY';
if (f.startsWith('yyyy')) return 'YYYY-MM-DD';
if (f.includes('/')) return 'DD/MM/YYYY';
return 'DD.MM.YYYY';
})();
const placeholder = normalisedFormat.toLowerCase();
// ISO → display. NOTE: no `$` anchor — Postgres serialises DATE columns
// as a full ISO datetime ("2026-04-06T00:00:00.000Z"), so we match the
// leading yyyy-MM-dd and ignore any trailing time. (SQLite returns the
// bare date string, which also matches.) Coerced to String in case a
// Date object slips through. Without this the field rendered the raw
// ISO timestamp on pg — see feedback_pg_date_columns_serialize.
const toDisplay = (iso: string): string => {
if (!iso) return '';
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso));
if (!m) return String(iso);
const [, y, mo, d] = m;
switch (normalisedFormat) {
case 'MM/DD/YYYY': return `${mo}/${d}/${y}`;
case 'YYYY-MM-DD': return `${y}-${mo}-${d}`;
case 'DD/MM/YYYY': return `${d}/${mo}/${y}`;
case 'DD.MM.YYYY':
default: return `${d}.${mo}.${y}`;
}
};
// display → ISO (accepts variant separators leniently)
const toIso = (raw: string): string => {
const s = raw.trim();
if (!s) return '';
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
const parts = s.split(/[./-]/);
if (parts.length !== 3) return '';
const [a, b, c] = parts;
let y: string, mo: string, d: string;
if (normalisedFormat === 'YYYY-MM-DD' || a.length === 4) {
[y, mo, d] = [a, b, c];
} else if (normalisedFormat === 'MM/DD/YYYY') {
[mo, d, y] = [a, b, c];
} else {
[d, mo, y] = [a, b, c];
}
if (!/^\d{1,2}$/.test(d) || !/^\d{1,2}$/.test(mo) || !/^\d{4}$/.test(y)) return '';
return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`;
};
const [text, setText] = React.useState(toDisplay(value));
React.useEffect(() => {
setText(toDisplay(value));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const openPicker = () => {
const el = nativeRef.current;
if (!el) return;
try {
el.showPicker();
} catch {
// showPicker throws on unsupported browsers / outside a user
// gesture — the text field stays fully usable for typing.
}
};
return (
<div className="w-full">
{label && (
<label htmlFor={inputId} className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1.5">
{label}
</label>
)}
<div className="relative">
<input
id={inputId}
value={text}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => {
const next = e.target.value;
setText(next);
// Commit live as soon as a complete, valid date is typed —
// don't wait for blur. Otherwise a value entered then submitted
// without blurring (or before React re-renders after the
// blur-time setState) is lost and the parent keeps its previous
// value (e.g. the import form's "today" default). toIso returns
// '' for partial/invalid input, so intermediate keystrokes emit
// nothing.
const iso = toIso(next);
if (iso) onChange(iso);
}}
onBlur={() => {
const iso = toIso(text);
if (iso) {
onChange(iso);
setText(toDisplay(iso));
} else if (!text.trim()) {
onChange('');
}
}}
className={clsx('input pr-10', error && 'border-red-500 focus-visible:ring-red-500')}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? `${inputId}-error` : undefined}
/>
<button
type="button"
onClick={openPicker}
disabled={disabled}
tabIndex={-1}
aria-label={label}
className="absolute inset-y-0 right-0 pr-3 flex items-center text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 disabled:opacity-50"
>
<Calendar className="w-5 h-5" />
</button>
{/* Visually hidden native picker — its only job is to provide
the calendar popup the icon button triggers. Value stays in
ISO so it's always parseable. */}
<input
ref={nativeRef}
type="date"
// Bare yyyy-MM-dd — a native date input rejects a full ISO
// datetime (pg serialisation), which would blank the picker.
value={value ? String(value).slice(0, 10) : ''}
min={min}
max={max}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
tabIndex={-1}
aria-hidden="true"
className="sr-only"
/>
</div>
{error && (
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-600 dark:text-red-400">
{error}
</p>
)}
</div>
);
};
@@ -0,0 +1,90 @@
/**
* Finder-style sortable table header.
*
* The admin list pages (invoices / quotes / contracts) drive sorting
* through a single server-side `sort` enum (e.g. 'customer_asc'). This
* component + the `useColumnSort` hook map that flat enum onto clickable
* column headers: clicking a column applies its ascending/descending
* variant, clicking the active column again flips direction. The active
* column shows a filled chevron; inactive sortable columns show a faint
* up/down hint so it's discoverable that the header is clickable.
*/
import React, { useCallback, useMemo, useState } from 'react';
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
export type SortDir = 'asc' | 'desc';
/** Maps one logical column to its two server-side sort enum values. */
export interface SortPair {
asc: string;
desc: string;
/** Direction applied when this column is first clicked. Defaults to 'asc'. */
defaultDir?: SortDir;
}
export type SortColumnMap = Record<string, SortPair>;
/**
* Holds the flat `sort` enum as the single source of truth and exposes
* the active column + a toggle that flips direction on re-click. Returns
* `sort` to feed straight into the list query and `setSort` for any
* legacy callers that still set the enum directly.
*/
export function useColumnSort<T extends string>(columns: SortColumnMap, initialSort: T) {
const [sort, setSort] = useState<T>(initialSort);
const active = useMemo(() => {
for (const [key, pair] of Object.entries(columns)) {
if (pair.asc === sort) return { key, dir: 'asc' as SortDir };
if (pair.desc === sort) return { key, dir: 'desc' as SortDir };
}
return { key: null as string | null, dir: 'asc' as SortDir };
}, [columns, sort]);
const toggle = useCallback((key: string) => {
const pair = columns[key];
if (!pair) return;
setSort((prev) => {
if (prev === pair.asc) return pair.desc as T;
if (prev === pair.desc) return pair.asc as T;
return (pair.defaultDir === 'desc' ? pair.desc : pair.asc) as T;
});
}, [columns]);
return { sort, setSort, activeKey: active.key, activeDir: active.dir, toggle };
}
interface SortableHeaderProps {
label: React.ReactNode;
columnKey: string;
activeKey: string | null;
activeDir: SortDir;
onSort: (key: string) => void;
align?: 'left' | 'right';
}
export const SortableHeader: React.FC<SortableHeaderProps> = ({
label, columnKey, activeKey, activeDir, onSort, align = 'left',
}) => {
const active = activeKey === columnKey;
return (
<th className={`px-3 py-2 ${align === 'right' ? 'text-right' : 'text-left'}`}>
<button
type="button"
onClick={() => onSort(columnKey)}
className={`group inline-flex items-center gap-1 font-medium transition-colors hover:text-theme ${
align === 'right' ? 'flex-row-reverse' : ''
} ${active ? 'text-theme' : ''}`}
>
<span>{label}</span>
{active ? (
activeDir === 'asc'
? <ChevronUp className="w-3 h-3" />
: <ChevronDown className="w-3 h-3" />
) : (
<ChevronsUpDown className="w-3 h-3 opacity-30 group-hover:opacity-60" />
)}
</button>
</th>
);
};
@@ -0,0 +1,106 @@
import React, { useEffect, useState } from 'react';
import { clsx } from 'clsx';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
/**
* Parse a free-typed time into canonical 24h "HH:MM", or null if
* unparseable. Tolerant of: "13:00", "1300", "9:5", "9", "1:00 PM",
* "1pm", "12 am". Lets the field accept input in whichever format it is
* displaying (24h or 12h) and normalise it back to storage form.
*/
export const parseTimeToHHMM = (raw: string): string | null => {
const s = raw.trim().toLowerCase();
if (!s) return null;
let ampm: 'am' | 'pm' | null = null;
let core = s;
const am = s.match(/(a|p)\.?m?\.?\s*$/);
if (am) {
ampm = am[1] === 'p' ? 'pm' : 'am';
core = s.slice(0, am.index).trim();
}
let h: number;
let mi: number;
const colon = core.match(/^(\d{1,2})\s*[:.]\s*(\d{1,2})$/);
if (colon) {
h = parseInt(colon[1], 10);
mi = parseInt(colon[2], 10);
} else {
const digits = core.replace(/\D/g, '');
if (!digits) return null;
if (digits.length <= 2) { h = parseInt(digits, 10); mi = 0; }
else if (digits.length === 3) { h = parseInt(digits.slice(0, 1), 10); mi = parseInt(digits.slice(1), 10); }
else { h = parseInt(digits.slice(0, 2), 10); mi = parseInt(digits.slice(2, 4), 10); }
}
if (Number.isNaN(h) || Number.isNaN(mi)) return null;
if (ampm === 'pm' && h < 12) h += 12;
if (ampm === 'am' && h === 12) h = 0;
h = Math.min(23, Math.max(0, h));
mi = Math.min(59, Math.max(0, mi));
return `${String(h).padStart(2, '0')}:${String(mi).padStart(2, '0')}`;
};
interface TimeFieldProps {
/** Canonical 24h "HH:MM" (or '' for empty). */
value: string;
/** Emits canonical 24h "HH:MM". */
onChange: (v: string) => void;
/** Optional label rendered above the field (matches the `Input` component). */
label?: string;
ariaLabel?: string;
/** Tailwind width/extra classes for the input; defaults to w-full. */
className?: string;
disabled?: boolean;
}
/**
* Time field that DISPLAYS in the admin's `general_time_format` (24h →
* "13:00", 12h → "01:00 PM") but always stores/emits canonical 24h
* "HH:MM". A plain text input, so the rendered format is identical in
* EVERY browser — native <input type="time"> ignores our setting (its
* 12h/24h chrome is browser-locale-controlled and Safari ignores the
* `lang` hint). Free text while typing; parsed + reformatted on blur,
* reverting to the last good value if unparseable.
*/
export const TimeField: React.FC<TimeFieldProps> = ({
value, onChange, label, ariaLabel, className, disabled,
}) => {
const { formatTime: fmtTime, timeFormat } = useLocalizedDate();
const display = (v: string) => (/^\d{1,2}:\d{2}/.test(v) ? fmtTime(v) : (v || ''));
const [text, setText] = useState(() => display(value));
// Re-sync when the external value or the format setting changes.
useEffect(() => { setText(display(value)); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [value, timeFormat]);
const commit = () => {
const parsed = parseTimeToHHMM(text);
if (parsed) {
setText(display(parsed));
if (parsed !== value) onChange(parsed);
} else {
setText(display(value));
}
};
const input = (
<input
type="text"
inputMode={timeFormat === '12h' ? 'text' : 'numeric'}
aria-label={ariaLabel || label}
placeholder={timeFormat === '12h' ? '1:00 PM' : 'HH:MM'}
value={text}
disabled={disabled}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
className={clsx('input', className || 'w-full')}
/>
);
if (!label) return input;
return (
<div className="w-full">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1.5">
{label}
</label>
{input}
</div>
);
};
+5
View File
@@ -1,6 +1,11 @@
export { Button } from './Button';
export { CMSContentBlock } from './CMSContentBlock';
export { Input } from './Input';
export { CountrySelect } from './CountrySelect';
export { LocalizedDateInput } from './LocalizedDateInput';
export { TimeField, parseTimeToHHMM } from './TimeField';
export { SortableHeader, useColumnSort } from './SortableHeader';
export type { SortDir, SortPair, SortColumnMap } from './SortableHeader';
export { Card, CardHeader, CardContent, CardFooter } from './Card';
export { Loading, LoadingSkeleton } from './Loading';
export { ErrorBoundary, PageErrorBoundary } from './ErrorBoundary';
@@ -32,6 +32,7 @@ interface GalleryLayoutProps {
footer_text?: string;
favicon_url?: string;
logo_url?: string;
logo_url_dark?: string;
logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | 'custom';
logo_max_height?: number;
logo_position?: 'left' | 'center' | 'right' | 'sidepanel';
@@ -122,6 +123,11 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { theme } = useTheme();
// Dark-mode logo variant. Symmetric fallback: a single uploaded logo
// serves both modes (dark → dark||light, light → light||dark).
const brandLogoUrl = theme.colorMode === 'dark'
? (brandingSettings?.logo_url_dark || brandingSettings?.logo_url)
: (brandingSettings?.logo_url || brandingSettings?.logo_url_dark);
const guestIdentity = useGuestIdentityOptional();
// Footer legal-link config. Cached aggressively because the toggle state
@@ -304,8 +310,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{shouldShowLogo('header') && (
<div className={`gallery-logo-wrapper flex-shrink-0 flex items-center gap-2 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
<img
src={brandingSettings?.logo_url ?
buildResourceUrl(brandingSettings.logo_url) :
src={brandLogoUrl ?
buildResourceUrl(brandLogoUrl) :
'/picpeak-logo-transparent.png'
}
alt={brandingSettings?.company_name || 'PicPeak'}
@@ -587,17 +593,17 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
{/* Logo - Show custom logo or fallback to PicPeak logo */}
{shouldShowLogo('hero') && (
<div className="mb-6">
<img
src={brandingSettings?.logo_url ?
buildResourceUrl(brandingSettings.logo_url) :
<img
src={brandLogoUrl ?
buildResourceUrl(brandLogoUrl) :
'/picpeak-logo-transparent.png'
}
}
alt={brandingSettings?.company_name || 'PicPeak'}
className={`${heroLogoSize.className} w-auto object-contain mx-auto`}
style={{
...(heroLogoSize.style || {}),
// Only apply brightness/invert filter to default logo; custom logos display as-is
filter: brandingSettings?.logo_url
filter: brandLogoUrl
? 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
}}
@@ -280,6 +280,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
footer_text: settingsData.branding_footer_text || '',
watermark_enabled: settingsData.branding_watermark_enabled || false,
logo_url: settingsData.branding_logo_url || null,
logo_url_dark: settingsData.branding_logo_url_dark || null,
logo_size: settingsData.branding_logo_size || 'medium',
logo_max_height: settingsData.branding_logo_max_height || 48,
logo_position: settingsData.branding_logo_position || 'left',
@@ -8,6 +8,7 @@ import type { Photo } from '../../../types';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
@@ -23,6 +24,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
feedbackOptions
}) => {
const { theme } = useTheme();
const { formatTime: fmtTime } = useLocalizedDate();
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
// Seed from server is_liked on first non-empty payload (#590 follow-up).
// Mount-only so refetches don't clobber in-session optimistic toggles.
@@ -124,7 +126,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
{/* Time label */}
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/60 text-white text-xs rounded">
{format(parseISO(photo.uploaded_at), 'h:mm a')}
{fmtTime(photo.uploaded_at)}
</div>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">