neues Admin UI Layout eingeführt. Alle Tests auf den neusten Stand gebracht.
This commit is contained in:
@@ -39,5 +39,6 @@
|
||||
"processing": {
|
||||
"title": "Anmeldung wird verarbeitet …",
|
||||
"copy": "Einen Moment bitte, wir bereiten dein Dashboard vor."
|
||||
}
|
||||
},
|
||||
"logoAlt": "Fotospiel Logo"
|
||||
}
|
||||
|
||||
@@ -1854,8 +1854,11 @@
|
||||
"accentColor": "Akzentfarbe",
|
||||
"fonts": "Schriften",
|
||||
"headingFont": "Überschrift-Schrift",
|
||||
"headingFontPlaceholder": "SF Pro Display",
|
||||
"bodyFont": "Fließtext-Schrift",
|
||||
"bodyFontPlaceholder": "SF Pro Text",
|
||||
"logo": "Logo",
|
||||
"logoAlt": "Logo",
|
||||
"replaceLogo": "Logo ersetzen",
|
||||
"removeLogo": "Entfernen",
|
||||
"logoHint": "Lade ein Logo hoch, um Einladungen und QR-Poster zu branden.",
|
||||
@@ -1898,24 +1901,47 @@
|
||||
"title": "Gästeverwaltung",
|
||||
"inviteTitle": "Mitglied einladen",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Alex Beispiel",
|
||||
"email": "E-Mail",
|
||||
"emailPlaceholder": "alex@example.com",
|
||||
"role": "Rolle",
|
||||
"roleMember": "Member",
|
||||
"roleAdmin": "Admin",
|
||||
"invite": "Einladung senden",
|
||||
"inviteSuccess": "Einladung gesendet",
|
||||
"inviteFailed": "Einladung fehlgeschlagen.",
|
||||
"emailInvalid": "Bitte eine gültige E-Mail-Adresse eingeben.",
|
||||
"search": "Mitglieder suchen",
|
||||
"filters": {
|
||||
"statusLabel": "Status",
|
||||
"roleLabel": "Rolle",
|
||||
"statusAll": "Alle Status",
|
||||
"statusPending": "Ausstehend",
|
||||
"statusActive": "Aktiv",
|
||||
"statusInvited": "Eingeladen",
|
||||
"roleAll": "Alle Rollen",
|
||||
"roleAdmin": "Admins",
|
||||
"roleMember": "Mitglieder"
|
||||
},
|
||||
"listTitle": "Team & Gäste",
|
||||
"copyInvite": "Einladungslink kopiert",
|
||||
"copyInviteFailed": "Kopieren nicht möglich",
|
||||
"copyInviteLabel": "Einladungslink kopieren",
|
||||
"copyEmail": "E-Mail kopiert",
|
||||
"copyEmailFailed": "Kopieren nicht möglich",
|
||||
"copyEmailLabel": "E-Mail kopieren",
|
||||
"empty": "Noch keine Einladungen.",
|
||||
"emptyTitle": "Team einladen",
|
||||
"emptyBody": "Sende die erste Einladung, damit Helfer Zugriff erhalten.",
|
||||
"emptyAction": "Erste Einladung senden",
|
||||
"emptyFilteredTitle": "Keine passenden Mitglieder",
|
||||
"emptyFilteredBody": "Passe Suche oder Filter an, um Mitglieder zu sehen.",
|
||||
"clearFilters": "Filter zurücksetzen",
|
||||
"fallbackName": "Gast",
|
||||
"admin": "Admin",
|
||||
"member": "Member",
|
||||
"statuses": {
|
||||
"pending": "Ausstehend",
|
||||
"active": "Aktiv",
|
||||
"invited": "Eingeladen",
|
||||
"unknown": "Unbekannt"
|
||||
},
|
||||
"confirmRemove": "Mitglied entfernen?",
|
||||
"remove": "Entfernen",
|
||||
"removeHint": "Dieses Mitglied verliert den Zugang zum Event.",
|
||||
@@ -2291,10 +2317,107 @@
|
||||
"fileTooLarge": "Wasserzeichen muss kleiner als 3 MB sein."
|
||||
}
|
||||
},
|
||||
"members": {
|
||||
"title": "Gästeverwaltung",
|
||||
"inviteTitle": "Mitglied einladen",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Alex Beispiel",
|
||||
"email": "E-Mail",
|
||||
"emailPlaceholder": "alex@example.com",
|
||||
"role": "Rolle",
|
||||
"roleMember": "Member",
|
||||
"roleAdmin": "Admin",
|
||||
"invite": "Einladung senden",
|
||||
"inviteSuccess": "Einladung gesendet",
|
||||
"inviteFailed": "Einladung fehlgeschlagen.",
|
||||
"search": "Mitglieder suchen",
|
||||
"listTitle": "Team & Gäste",
|
||||
"copyInvite": "Einladungslink kopiert",
|
||||
"copyInviteFailed": "Kopieren nicht möglich",
|
||||
"copyInviteLabel": "Einladungslink kopieren",
|
||||
"empty": "Noch keine Einladungen.",
|
||||
"emptyTitle": "Team einladen",
|
||||
"emptyBody": "Sende die erste Einladung, damit Helfer Zugriff erhalten.",
|
||||
"emptyAction": "Erste Einladung senden",
|
||||
"admin": "Admin",
|
||||
"member": "Member",
|
||||
"confirmRemove": "Mitglied entfernen?",
|
||||
"remove": "Entfernen",
|
||||
"removeHint": "Dieses Mitglied verliert den Zugang zum Event.",
|
||||
"removeSuccess": "Mitglied entfernt",
|
||||
"removeFailed": "Mitglied konnte nicht entfernt werden."
|
||||
},
|
||||
"tasks": {
|
||||
"disabledTitle": "Task-Modus ist für dieses Event aus",
|
||||
"disabledBody": "Gäste sehen nur den Fotofeed. Aktiviere Tasks in den Event-Einstellungen, um sie wieder anzuzeigen.",
|
||||
"title": "Tasks & Checklisten",
|
||||
"quickNav": "Schnellzugriff",
|
||||
"sections": {
|
||||
"assigned": "Zugewiesen",
|
||||
"library": "Bibliothek",
|
||||
"collections": "Sammlungen",
|
||||
"emotions": "Emotionen"
|
||||
},
|
||||
"summary": {
|
||||
"assigned": "Zugewiesen",
|
||||
"library": "Bibliothek",
|
||||
"collections": "Sammlungen",
|
||||
"emotions": "Emotionen"
|
||||
},
|
||||
"actions": "Aktionen",
|
||||
"assigned": "Task hinzugefügt",
|
||||
"updateFailed": "Task konnte nicht gespeichert werden.",
|
||||
"created": "Aufgabe gespeichert",
|
||||
"removed": "Aufgabe entfernt",
|
||||
"imported": "Aufgabenpaket importiert",
|
||||
"saveTask": "Aufgabe speichern",
|
||||
"add": "Hinzufügen",
|
||||
"empty": "Noch keine Aufgaben zugewiesen.",
|
||||
"emptyHint": "Lege jetzt Tasks an oder importiere ein Paket.",
|
||||
"emptyTitle": "Noch keine Tasks",
|
||||
"emptyBody": "Lege Tasks an oder importiere ein Paket für dein Event.",
|
||||
"emptyActionTask": "Task hinzufügen",
|
||||
"emptyActionPack": "Paket importieren",
|
||||
"addTask": "Aufgabe hinzufügen",
|
||||
"addTaskHint": "Erstelle eine neue Aufgabe für dieses Event.",
|
||||
"import": "Aufgabenpaket importieren",
|
||||
"importHint": "Nutze vordefinierte Pakete für deinen Event-Typ.",
|
||||
"search": "Tasks durchsuchen",
|
||||
"emotionFilter": "Emotion filtern",
|
||||
"customEmotion": "Eigene Emotion",
|
||||
"allEmotions": "Alle",
|
||||
"count": "{{count}} Tasks",
|
||||
"library": "Weitere Aufgaben",
|
||||
"hideLibrary": "Bibliothek ausblenden",
|
||||
"viewAllLibrary": "Alle anzeigen",
|
||||
"libraryEmpty": "Keine weiteren Aufgaben verfügbar.",
|
||||
"hideCollections": "Pakete ausblenden",
|
||||
"showCollections": "Alle Pakete anzeigen",
|
||||
"collectionsEmpty": "Keine Pakete vorhanden.",
|
||||
"bulkAdd": "Bulk add",
|
||||
"manageEmotions": "Emotionen verwalten",
|
||||
"manageEmotionsHint": "Filtere und halte deine Taxonomie gepflegt.",
|
||||
"saveEmotion": "Emotion speichern",
|
||||
"emotionName": "Name",
|
||||
"emotionNamePlaceholder": "z. B. Joy",
|
||||
"emotionColor": "Farbe",
|
||||
"emotionRemoved": "Emotion entfernt",
|
||||
"emotionSaved": "Emotion gespeichert",
|
||||
"emotionNone": "Keine",
|
||||
"emotion": "Emotion",
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Optionale Hinweise",
|
||||
"titleLabel": "Titel",
|
||||
"titlePlaceholder": "z. B. Erstes Gruppenfoto",
|
||||
"bulkHint": "Eine Aufgabe pro Zeile. Sie werden erstellt und dem Event hinzugefügt.",
|
||||
"bulkPlaceholder": "z. B.\nBraut & Bräutigam Porträt\nGruppenfoto mit Hauptgästen"
|
||||
},
|
||||
"qr": {
|
||||
"title": "QR-Code & Druck-Layouts",
|
||||
"heroTitle": "Einlass-QR-Code",
|
||||
"description": "Scannen, um zur Gäste-App zu gelangen.",
|
||||
"qrAlt": "QR-Code",
|
||||
"previewAlt": "QR-Layout Vorschau",
|
||||
"bottomNote": "Unterer Hinweistext",
|
||||
"missing": "Kein QR-Link vorhanden",
|
||||
"download": "Download",
|
||||
@@ -2306,15 +2429,45 @@
|
||||
"layouts": "Druck-Layouts",
|
||||
"preview": "Anpassen & Exportieren",
|
||||
"createLink": "Neuen QR-Link erstellen",
|
||||
"mobileLinkLabel": "Mobiler Link",
|
||||
"created": "Neuer QR-Link erstellt",
|
||||
"createFailed": "Link konnte nicht erstellt werden.",
|
||||
"backgroundPicker": "Hintergrund auswählen ({{formatLabel}})",
|
||||
"backgroundPickerFoldable": "Hintergrund für A5 (Gradient/Farbe)",
|
||||
"backgroundNote": "Diese Presets sind für A4-Hochformat. Spiegelung erfolgt automatisch bei Tischkarten.",
|
||||
"foldableBackgroundNote": "Für A5 Tischkarten bitte Gradient oder Vollfarbe wählen.",
|
||||
"gradients": "Gradienten",
|
||||
"colors": "Vollfarbe",
|
||||
"headline": "Headline",
|
||||
"subtitle": "Untertitel",
|
||||
"align": "Ausrichtung",
|
||||
"lineHeight": "Zeilenhöhe",
|
||||
"fontFamily": "Schriftfamilie",
|
||||
"fontSize": "Schriftgröße (px)",
|
||||
"positionX": "X (%)",
|
||||
"positionY": "Y (%)",
|
||||
"width": "Breite (%)",
|
||||
"size": "Größe (%)",
|
||||
"exportPdf": "PDF exportieren",
|
||||
"exportPng": "PNG exportieren",
|
||||
"orientation": {
|
||||
"landscape": "Querformat",
|
||||
"portrait": "Hochformat"
|
||||
},
|
||||
"formatLabel": {
|
||||
"standard": "{{paper}} {{orientation}}",
|
||||
"foldable": "{{paper}} {{orientation}} (doppelt A5/gespiegelt)"
|
||||
},
|
||||
"backgroundPresets": {
|
||||
"blueFloral": "Blau Floral",
|
||||
"goldFrame": "Goldrahmen",
|
||||
"greenFloral": "Grün Floral"
|
||||
},
|
||||
"gradientPresets": {
|
||||
"softLilac": "Sanftes Flieder",
|
||||
"pastel": "Pastell",
|
||||
"midnight": "Mitternacht"
|
||||
},
|
||||
"format": {
|
||||
"poster": "A4 Poster",
|
||||
"posterSubtitle": "Hochformat für Aushänge",
|
||||
@@ -2464,6 +2617,7 @@
|
||||
"ctaHint": "Beide Felder werden benötigt, um einen Button zu senden.",
|
||||
"ctaError": "CTA-Label und Link müssen zusammen ausgefüllt werden.",
|
||||
"expiresIn": "Läuft ab in (Minuten)",
|
||||
"expiresPlaceholder": "60",
|
||||
"priority": "Priorität",
|
||||
"priorityValue": "Priorität {{value}}",
|
||||
"send": "Benachrichtigung senden",
|
||||
|
||||
@@ -39,5 +39,6 @@
|
||||
"processing": {
|
||||
"title": "Signing you in …",
|
||||
"copy": "One moment please while we prepare your dashboard."
|
||||
}
|
||||
},
|
||||
"logoAlt": "Fotospiel logo"
|
||||
}
|
||||
|
||||
@@ -1858,8 +1858,11 @@
|
||||
"accentColor": "Accent color",
|
||||
"fonts": "Fonts",
|
||||
"headingFont": "Headline font",
|
||||
"headingFontPlaceholder": "SF Pro Display",
|
||||
"bodyFont": "Body font",
|
||||
"bodyFontPlaceholder": "SF Pro Text",
|
||||
"logo": "Logo",
|
||||
"logoAlt": "Logo",
|
||||
"replaceLogo": "Replace logo",
|
||||
"removeLogo": "Remove",
|
||||
"logoHint": "Upload a logo to brand guest invites and QR posters.",
|
||||
@@ -1902,24 +1905,47 @@
|
||||
"title": "Guest management",
|
||||
"inviteTitle": "Invite member",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Alex Example",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "alex@example.com",
|
||||
"role": "Role",
|
||||
"roleMember": "Member",
|
||||
"roleAdmin": "Admin",
|
||||
"invite": "Send invite",
|
||||
"inviteSuccess": "Invitation sent",
|
||||
"inviteFailed": "Invitation failed.",
|
||||
"emailInvalid": "Please enter a valid email address.",
|
||||
"search": "Search members",
|
||||
"filters": {
|
||||
"statusLabel": "Status",
|
||||
"roleLabel": "Role",
|
||||
"statusAll": "All statuses",
|
||||
"statusPending": "Pending",
|
||||
"statusActive": "Active",
|
||||
"statusInvited": "Invited",
|
||||
"roleAll": "All roles",
|
||||
"roleAdmin": "Admins",
|
||||
"roleMember": "Members"
|
||||
},
|
||||
"listTitle": "Team & guests",
|
||||
"copyInvite": "Invite link copied",
|
||||
"copyInviteFailed": "Copy failed",
|
||||
"copyInviteLabel": "Copy invite link",
|
||||
"copyEmail": "Email copied",
|
||||
"copyEmailFailed": "Copy failed",
|
||||
"copyEmailLabel": "Copy email",
|
||||
"empty": "No invitations yet.",
|
||||
"emptyTitle": "Invite your team",
|
||||
"emptyBody": "Send the first invite so helpers can access the event.",
|
||||
"emptyAction": "Send first invite",
|
||||
"emptyFilteredTitle": "No matching members",
|
||||
"emptyFilteredBody": "Adjust your search or filters to see members.",
|
||||
"clearFilters": "Clear filters",
|
||||
"fallbackName": "Guest",
|
||||
"admin": "Admin",
|
||||
"member": "Member",
|
||||
"statuses": {
|
||||
"pending": "Pending",
|
||||
"active": "Active",
|
||||
"invited": "Invited",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"confirmRemove": "Remove member?",
|
||||
"remove": "Remove",
|
||||
"removeHint": "This member will lose access to the event.",
|
||||
@@ -2295,10 +2321,107 @@
|
||||
"fileTooLarge": "Watermark must be under 3 MB."
|
||||
}
|
||||
},
|
||||
"members": {
|
||||
"title": "Guest management",
|
||||
"inviteTitle": "Invite member",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "Alex Example",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "alex@example.com",
|
||||
"role": "Role",
|
||||
"roleMember": "Member",
|
||||
"roleAdmin": "Admin",
|
||||
"invite": "Send invite",
|
||||
"inviteSuccess": "Invitation sent",
|
||||
"inviteFailed": "Invitation failed.",
|
||||
"search": "Search members",
|
||||
"listTitle": "Team & guests",
|
||||
"copyInvite": "Invite link copied",
|
||||
"copyInviteFailed": "Copy failed",
|
||||
"copyInviteLabel": "Copy invite link",
|
||||
"empty": "No invitations yet.",
|
||||
"emptyTitle": "Invite your team",
|
||||
"emptyBody": "Send the first invite so helpers can access the event.",
|
||||
"emptyAction": "Send first invite",
|
||||
"admin": "Admin",
|
||||
"member": "Member",
|
||||
"confirmRemove": "Remove member?",
|
||||
"remove": "Remove",
|
||||
"removeHint": "This member will lose access to the event.",
|
||||
"removeSuccess": "Member removed",
|
||||
"removeFailed": "Member could not be removed."
|
||||
},
|
||||
"tasks": {
|
||||
"disabledTitle": "Task mode is off for this event",
|
||||
"disabledBody": "Guests only see the photo feed. Enable tasks in the event settings to show them again.",
|
||||
"title": "Tasks & checklists",
|
||||
"quickNav": "Quick jump",
|
||||
"sections": {
|
||||
"assigned": "Assigned",
|
||||
"library": "Library",
|
||||
"collections": "Collections",
|
||||
"emotions": "Emotions"
|
||||
},
|
||||
"summary": {
|
||||
"assigned": "Assigned",
|
||||
"library": "Library",
|
||||
"collections": "Collections",
|
||||
"emotions": "Emotions"
|
||||
},
|
||||
"actions": "Actions",
|
||||
"assigned": "Task added",
|
||||
"updateFailed": "Task could not be saved.",
|
||||
"created": "Task saved",
|
||||
"removed": "Task removed",
|
||||
"imported": "Task pack imported",
|
||||
"saveTask": "Save task",
|
||||
"add": "Add",
|
||||
"empty": "No tasks assigned yet.",
|
||||
"emptyHint": "Add tasks or import a pack.",
|
||||
"emptyTitle": "No tasks yet",
|
||||
"emptyBody": "Create tasks or import a pack for your event.",
|
||||
"emptyActionTask": "Add task",
|
||||
"emptyActionPack": "Import pack",
|
||||
"addTask": "Add task",
|
||||
"addTaskHint": "Create a new task for this event.",
|
||||
"import": "Import pack",
|
||||
"importHint": "Use predefined packs for your event type.",
|
||||
"search": "Search tasks",
|
||||
"emotionFilter": "Emotion filter",
|
||||
"customEmotion": "Custom emotion",
|
||||
"allEmotions": "All",
|
||||
"count": "{{count}} tasks",
|
||||
"library": "More tasks",
|
||||
"hideLibrary": "Hide library",
|
||||
"viewAllLibrary": "View all",
|
||||
"libraryEmpty": "No more tasks available.",
|
||||
"hideCollections": "Hide collections",
|
||||
"showCollections": "Show all",
|
||||
"collectionsEmpty": "No collections available.",
|
||||
"bulkAdd": "Bulk add",
|
||||
"manageEmotions": "Manage emotions",
|
||||
"manageEmotionsHint": "Filter and keep your taxonomy tidy.",
|
||||
"saveEmotion": "Save emotion",
|
||||
"emotionName": "Name",
|
||||
"emotionNamePlaceholder": "e.g. Joy",
|
||||
"emotionColor": "Color",
|
||||
"emotionRemoved": "Emotion removed",
|
||||
"emotionSaved": "Emotion saved",
|
||||
"emotionNone": "None",
|
||||
"emotion": "Emotion",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Optional notes",
|
||||
"titleLabel": "Title",
|
||||
"titlePlaceholder": "e.g. First group photo",
|
||||
"bulkHint": "One task per line. These will be created and added to the event.",
|
||||
"bulkPlaceholder": "e.g.\nBride & groom portrait\nGroup photo main guests"
|
||||
},
|
||||
"qr": {
|
||||
"title": "QR Code & Print Layouts",
|
||||
"heroTitle": "Entrance QR Code",
|
||||
"description": "Scan to access the event guest app.",
|
||||
"qrAlt": "QR code",
|
||||
"previewAlt": "QR layout preview",
|
||||
"bottomNote": "Bottom note text",
|
||||
"missing": "No QR link available",
|
||||
"download": "Download",
|
||||
@@ -2310,15 +2433,45 @@
|
||||
"layouts": "Print Layouts",
|
||||
"preview": "Customize & Export",
|
||||
"createLink": "Create new QR link",
|
||||
"mobileLinkLabel": "Mobile link",
|
||||
"created": "New QR link created",
|
||||
"createFailed": "Could not create link.",
|
||||
"backgroundPicker": "Select background ({{formatLabel}})",
|
||||
"backgroundPickerFoldable": "Background for A5 (gradient/solid)",
|
||||
"backgroundNote": "These presets are designed for A4 portrait. Mirroring is automatic for table cards.",
|
||||
"foldableBackgroundNote": "For A5 table cards choose a gradient or solid colour.",
|
||||
"gradients": "Gradients",
|
||||
"colors": "Solid colour",
|
||||
"headline": "Headline",
|
||||
"subtitle": "Subtitle",
|
||||
"align": "Align",
|
||||
"lineHeight": "Line height",
|
||||
"fontFamily": "Font family",
|
||||
"fontSize": "Font size (px)",
|
||||
"positionX": "X (%)",
|
||||
"positionY": "Y (%)",
|
||||
"width": "Width (%)",
|
||||
"size": "Size (%)",
|
||||
"exportPdf": "Export PDF",
|
||||
"exportPng": "Export PNG",
|
||||
"orientation": {
|
||||
"landscape": "Landscape",
|
||||
"portrait": "Portrait"
|
||||
},
|
||||
"formatLabel": {
|
||||
"standard": "{{paper}} {{orientation}}",
|
||||
"foldable": "{{paper}} {{orientation}} (double A5/mirrored)"
|
||||
},
|
||||
"backgroundPresets": {
|
||||
"blueFloral": "Blue floral",
|
||||
"goldFrame": "Gold frame",
|
||||
"greenFloral": "Green floral"
|
||||
},
|
||||
"gradientPresets": {
|
||||
"softLilac": "Soft lilac",
|
||||
"pastel": "Pastel",
|
||||
"midnight": "Midnight"
|
||||
},
|
||||
"format": {
|
||||
"poster": "A4 Poster",
|
||||
"posterSubtitle": "Portrait for posters",
|
||||
@@ -2468,6 +2621,7 @@
|
||||
"ctaHint": "Both fields are required to add a button.",
|
||||
"ctaError": "CTA label and link are required together.",
|
||||
"expiresIn": "Expires in (minutes)",
|
||||
"expiresPlaceholder": "60",
|
||||
"priority": "Priority",
|
||||
"priorityValue": "Priority {{value}}",
|
||||
"send": "Send notification",
|
||||
|
||||
@@ -85,7 +85,7 @@ function AdminApp() {
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="font-[Montserrat] text-[13px] font-normal leading-[1.5] text-slate-700">
|
||||
<div className="bg-[#FFF8F5] font-[Manrope] text-[14px] font-normal leading-[1.6] text-[#1F2937] dark:bg-[#15121A] dark:text-slate-100">
|
||||
<RouterProvider router={router} />
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
@@ -20,11 +20,13 @@ import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { ADMIN_EVENT_VIEW_PATH, adminPath } from '../constants';
|
||||
import { buildPackageUsageMetrics, PackageUsageMetric, usagePercent } from './billingUsage';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
export default function MobileBillingPage() {
|
||||
const { t } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
|
||||
const [packages, setPackages] = React.useState<TenantPackageSummary[]>([]);
|
||||
const [activePackage, setActivePackage] = React.useState<TenantPackageSummary | null>(null);
|
||||
const [transactions, setTransactions] = React.useState<PaddleTransactionSummary[]>([]);
|
||||
@@ -107,13 +109,13 @@ export default function MobileBillingPage() {
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
<RefreshCcw size={18} color="#0f172a" />
|
||||
<RefreshCcw size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
|
||||
@@ -122,12 +124,12 @@ export default function MobileBillingPage() {
|
||||
|
||||
<MobileCard space="$2" ref={packagesRef as any}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Package size={18} color="#0f172a" />
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
<Package size={18} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('billing.sections.packages.title', 'Packages')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.packages.hint', 'Active package, limits, and history at a glance.')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
@@ -136,7 +138,7 @@ export default function MobileBillingPage() {
|
||||
disabled={portalBusy}
|
||||
/>
|
||||
{loading ? (
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('common.loading', 'Lädt...')}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -159,21 +161,21 @@ export default function MobileBillingPage() {
|
||||
|
||||
<MobileCard space="$2" ref={invoicesRef as any}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Receipt size={18} color="#0f172a" />
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
<Receipt size={18} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('billing.sections.invoices.title', 'Invoices & Payments')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.invoices.hint', 'Review transactions and download receipts.')}
|
||||
</Text>
|
||||
{loading ? (
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('common.loading', 'Lädt...')}
|
||||
</Text>
|
||||
) : transactions.length === 0 ? (
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{t('billing.sections.invoices.empty', 'Keine Zahlungen gefunden.')}
|
||||
</Text>
|
||||
<CTAButton label={t('billing.actions.openPackages', 'Open packages')} onPress={scrollToPackages} />
|
||||
@@ -182,31 +184,31 @@ export default function MobileBillingPage() {
|
||||
) : (
|
||||
<YStack space="$1.5">
|
||||
{transactions.slice(0, 8).map((trx) => (
|
||||
<XStack key={trx.id} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor="#e5e7eb" paddingVertical="$1.5">
|
||||
<XStack key={trx.id} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor={border} paddingVertical="$1.5">
|
||||
<YStack>
|
||||
<Text fontSize="$sm" color="#0f172a" fontWeight="700">
|
||||
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
||||
{trx.status ?? '—'}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{formatDate(trx.created_at)}
|
||||
</Text>
|
||||
{trx.origin ? (
|
||||
<Text fontSize="$xs" color="#9ca3af">
|
||||
<Text fontSize="$xs" color={subtle}>
|
||||
{trx.origin}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
<YStack alignItems="flex-end">
|
||||
<Text fontSize="$sm" color="#0f172a" fontWeight="700">
|
||||
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
||||
{formatAmount(trx.amount, trx.currency)}
|
||||
</Text>
|
||||
{trx.tax ? (
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.transactions.labels.tax', { value: formatAmount(trx.tax, trx.currency) })}
|
||||
</Text>
|
||||
) : null}
|
||||
{trx.receipt_url ? (
|
||||
<a href={trx.receipt_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: '#2563eb' }}>
|
||||
<a href={trx.receipt_url} target="_blank" rel="noreferrer" style={{ fontSize: 12, color: primary }}>
|
||||
{t('billing.sections.transactions.labels.receipt', 'Beleg')}
|
||||
</a>
|
||||
) : null}
|
||||
@@ -220,20 +222,20 @@ export default function MobileBillingPage() {
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Sparkles size={18} color="#0f172a" />
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
<Sparkles size={18} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('billing.sections.addOns.title', 'Add-ons')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('billing.sections.addOns.hint', 'Track extra photo, guest, or time bundles per event.')}
|
||||
</Text>
|
||||
{loading ? (
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('common.loading', 'Lädt...')}
|
||||
</Text>
|
||||
) : addons.length === 0 ? (
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -251,19 +253,25 @@ export default function MobileBillingPage() {
|
||||
|
||||
function PackageCard({ pkg, label, isActive = false }: { pkg: TenantPackageSummary; label?: string; isActive?: boolean }) {
|
||||
const { t } = useTranslation('management');
|
||||
const { border, primary, accentSoft, textStrong, muted } = useAdminTheme();
|
||||
const remaining = pkg.remaining_events ?? (pkg.package_limits?.max_events_per_year as number | undefined) ?? 0;
|
||||
const expires = pkg.expires_at ? formatDate(pkg.expires_at) : null;
|
||||
const usageMetrics = buildPackageUsageMetrics(pkg);
|
||||
return (
|
||||
<MobileCard borderColor={isActive ? '#2563eb' : '#e5e7eb'} borderWidth={isActive ? 2 : 1} backgroundColor={isActive ? '#eff6ff' : undefined} space="$2">
|
||||
<MobileCard
|
||||
borderColor={isActive ? primary : border}
|
||||
borderWidth={isActive ? 2 : 1}
|
||||
backgroundColor={isActive ? accentSoft : undefined}
|
||||
space="$2"
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{pkg.package_name ?? t('mobileBilling.packageFallback', 'Package')}
|
||||
</Text>
|
||||
{label ? <PillBadge tone="success">{label}</PillBadge> : null}
|
||||
</XStack>
|
||||
{expires ? (
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{expires}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -297,6 +305,7 @@ function renderFeatureBadge(pkg: TenantPackageSummary, t: any, key: string, labe
|
||||
|
||||
function UsageBar({ metric }: { metric: PackageUsageMetric }) {
|
||||
const { t } = useTranslation('management');
|
||||
const { muted, textStrong, border, primary, subtle } = useAdminTheme();
|
||||
const labelMap: Record<PackageUsageMetric['key'], string> = {
|
||||
events: t('mobileBilling.usage.events', 'Events'),
|
||||
guests: t('mobileBilling.usage.guests', 'Guests'),
|
||||
@@ -319,18 +328,18 @@ function UsageBar({ metric }: { metric: PackageUsageMetric }) {
|
||||
return (
|
||||
<YStack space="$1.5">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{labelMap[metric.key]}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#0f172a" fontWeight="700">
|
||||
<Text fontSize="$xs" color={textStrong} fontWeight="700">
|
||||
{valueText}
|
||||
</Text>
|
||||
</XStack>
|
||||
<YStack height={6} borderRadius={999} backgroundColor="#e5e7eb" overflow="hidden">
|
||||
<YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? '#2563eb' : '#94a3b8'} />
|
||||
<YStack height={6} borderRadius={999} backgroundColor={border} overflow="hidden">
|
||||
<YStack height="100%" width={`${fill}%`} backgroundColor={hasUsage ? primary : subtle} />
|
||||
</YStack>
|
||||
{remainingText ? (
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{remainingText}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -353,6 +362,7 @@ function formatAmount(value: number | null | undefined, currency: string | null
|
||||
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
||||
const { t } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const { border, textStrong, text, muted, subtle, primary } = useAdminTheme();
|
||||
const labels: Record<TenantAddonHistoryEntry['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
|
||||
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
|
||||
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
|
||||
@@ -380,9 +390,9 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<MobileCard borderColor="#e5e7eb" padding="$3" space="$1.5">
|
||||
<MobileCard borderColor={border} padding="$3" space="$1.5">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#0f172a">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{addon.label ?? addon.addon_key}
|
||||
</Text>
|
||||
<PillBadge tone={status.tone}>{status.text}</PillBadge>
|
||||
@@ -391,25 +401,25 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
|
||||
eventPath ? (
|
||||
<Pressable onPress={() => navigate(eventPath)}>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$xs" color="#0f172a" fontWeight="600">
|
||||
<Text fontSize="$xs" color={textStrong} fontWeight="600">
|
||||
{eventName}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#2563eb" fontWeight="700">
|
||||
<Text fontSize="$xs" color={primary} fontWeight="700">
|
||||
{t('mobileBilling.openEvent', 'Open event')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
) : (
|
||||
<Text fontSize="$xs" color="#9ca3af">
|
||||
<Text fontSize="$xs" color={subtle}>
|
||||
{eventName}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
{impactBadges}
|
||||
<Text fontSize="$sm" color="#0f172a" marginTop="$1.5">
|
||||
<Text fontSize="$sm" color={text} marginTop="$1.5">
|
||||
{formatAmount(addon.amount, addon.currency)}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{formatDate(addon.purchased_at)}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MobileSheet } from './components/Sheet';
|
||||
import toast from 'react-hot-toast';
|
||||
import { adminPath } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
||||
|
||||
type BrandingForm = {
|
||||
primary: string;
|
||||
@@ -54,11 +55,12 @@ export default function MobileBrandingPage() {
|
||||
const slug = slugParam ?? null;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, subtle, border, primary, accentSoft, danger, surfaceMuted, surface } = useAdminTheme();
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [form, setForm] = React.useState<BrandingForm>({
|
||||
primary: '#007AFF',
|
||||
accent: '#5AD2F4',
|
||||
primary: ADMIN_COLORS.primary,
|
||||
accent: ADMIN_COLORS.accent,
|
||||
headingFont: '',
|
||||
bodyFont: '',
|
||||
logoDataUrl: '',
|
||||
@@ -118,8 +120,8 @@ export default function MobileBrandingPage() {
|
||||
}, [showFontsSheet, fontsLoaded]);
|
||||
|
||||
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
|
||||
const previewHeadingFont = form.headingFont || 'Montserrat';
|
||||
const previewBodyFont = form.bodyFont || 'Montserrat';
|
||||
const previewHeadingFont = form.headingFont || 'Fraunces';
|
||||
const previewBodyFont = form.bodyFont || 'Manrope';
|
||||
const watermarkAllowed = event?.package?.watermark_allowed !== false;
|
||||
const brandingAllowed = isBrandingAllowed(event ?? null);
|
||||
const watermarkLocked = watermarkAllowed && !brandingAllowed;
|
||||
@@ -229,7 +231,7 @@ export default function MobileBrandingPage() {
|
||||
return (
|
||||
<>
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.watermark.previewTitle', 'Watermark Preview')}
|
||||
</Text>
|
||||
<WatermarkPreview
|
||||
@@ -244,7 +246,7 @@ export default function MobileBrandingPage() {
|
||||
|
||||
{disabled ? (
|
||||
<InfoBadge
|
||||
icon={<Lock size={16} color="#b91c1c" />}
|
||||
icon={<Lock size={16} color={danger} />}
|
||||
text={t('events.watermark.lockedDisabled', 'Kein Wasserzeichen in diesem Paket.')}
|
||||
tone="danger"
|
||||
/>
|
||||
@@ -252,13 +254,13 @@ export default function MobileBrandingPage() {
|
||||
|
||||
{watermarkLocked ? (
|
||||
<InfoBadge
|
||||
icon={<Lock size={16} color="#111827" />}
|
||||
icon={<Lock size={16} color={textStrong} />}
|
||||
text={t('events.watermark.lockedBranding', 'Custom-Wasserzeichen ist im aktuellen Paket gesperrt. Standard wird genutzt.')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.watermark.title', 'Wasserzeichen')}
|
||||
</Text>
|
||||
|
||||
@@ -291,7 +293,7 @@ export default function MobileBrandingPage() {
|
||||
|
||||
{mode === 'custom' && !controlsLocked ? (
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.watermark.upload', 'Wasserzeichen hochladen')}
|
||||
</Text>
|
||||
<Pressable onPress={() => document.getElementById('watermark-upload-input')?.click()}>
|
||||
@@ -302,11 +304,11 @@ export default function MobileBrandingPage() {
|
||||
paddingVertical="$2.5"
|
||||
borderRadius={12}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
backgroundColor="white"
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
>
|
||||
<UploadCloud size={18} color="#007AFF" />
|
||||
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
|
||||
<UploadCloud size={18} color={primary} />
|
||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||
{watermarkForm.assetPath
|
||||
? t('events.watermark.replace', 'Wasserzeichen ersetzen')
|
||||
: t('events.watermark.uploadCta', 'PNG/SVG/JPG (max. 3 MB)')}
|
||||
@@ -334,7 +336,7 @@ export default function MobileBrandingPage() {
|
||||
reader.readAsDataURL(file);
|
||||
}}
|
||||
/>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.watermark.uploadHint', 'PNG mit transparenter Fläche empfohlen.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
@@ -342,7 +344,7 @@ export default function MobileBrandingPage() {
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.watermark.placement', 'Position & Größe')}
|
||||
</Text>
|
||||
<PositionGrid
|
||||
@@ -408,13 +410,13 @@ export default function MobileBrandingPage() {
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => handleSave()} ariaLabel={t('common.save', 'Save')}>
|
||||
<Save size={18} color="#007AFF" />
|
||||
<Save size={18} color={primary} />
|
||||
</HeaderActionButton>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
@@ -430,17 +432,17 @@ export default function MobileBrandingPage() {
|
||||
{activeTab === 'branding' ? (
|
||||
<>
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.branding.previewTitle', 'Guest App Preview')}
|
||||
</Text>
|
||||
<YStack borderRadius={16} borderWidth={1} borderColor="#e5e7eb" backgroundColor="#f8fafc" padding="$3" space="$2" alignItems="center">
|
||||
<YStack width="100%" borderRadius={12} backgroundColor="white" borderWidth={1} borderColor="#e5e7eb" overflow="hidden">
|
||||
<YStack borderRadius={16} borderWidth={1} borderColor={border} backgroundColor={surfaceMuted} padding="$3" space="$2" alignItems="center">
|
||||
<YStack width="100%" borderRadius={12} backgroundColor={surface} borderWidth={1} borderColor={border} overflow="hidden">
|
||||
<YStack backgroundColor={form.primary} height={64} />
|
||||
<YStack padding="$3" space="$1.5">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827" style={{ fontFamily: previewHeadingFont }}>
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong} style={{ fontFamily: previewHeadingFont }}>
|
||||
{previewTitle}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="#4b5563" style={{ fontFamily: previewBodyFont }}>
|
||||
<Text fontSize="$sm" color={muted} style={{ fontFamily: previewBodyFont }}>
|
||||
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
|
||||
</Text>
|
||||
<XStack space="$2" marginTop="$1">
|
||||
@@ -453,7 +455,7 @@ export default function MobileBrandingPage() {
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.branding.colors', 'Colors')}
|
||||
</Text>
|
||||
<ColorField
|
||||
@@ -469,13 +471,13 @@ export default function MobileBrandingPage() {
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.branding.fonts', 'Fonts')}
|
||||
</Text>
|
||||
<InputField
|
||||
label={t('events.branding.headingFont', 'Headline Font')}
|
||||
value={form.headingFont}
|
||||
placeholder="SF Pro Display"
|
||||
placeholder={t('events.branding.headingFontPlaceholder', 'SF Pro Display')}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, headingFont: value }))}
|
||||
onPicker={() => {
|
||||
setFontField('heading');
|
||||
@@ -485,7 +487,7 @@ export default function MobileBrandingPage() {
|
||||
<InputField
|
||||
label={t('events.branding.bodyFont', 'Body Font')}
|
||||
value={form.bodyFont}
|
||||
placeholder="SF Pro Text"
|
||||
placeholder={t('events.branding.bodyFontPlaceholder', 'SF Pro Text')}
|
||||
onChange={(value) => setForm((prev) => ({ ...prev, bodyFont: value }))}
|
||||
onPicker={() => {
|
||||
setFontField('body');
|
||||
@@ -495,14 +497,14 @@ export default function MobileBrandingPage() {
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.branding.logo', 'Logo')}
|
||||
</Text>
|
||||
<YStack
|
||||
borderRadius={14}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
backgroundColor="#f8fafc"
|
||||
borderColor={border}
|
||||
backgroundColor={surfaceMuted}
|
||||
padding="$3"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
@@ -510,7 +512,11 @@ export default function MobileBrandingPage() {
|
||||
>
|
||||
{form.logoDataUrl ? (
|
||||
<>
|
||||
<img src={form.logoDataUrl} alt="Logo" style={{ maxHeight: 80, maxWidth: '100%', objectFit: 'contain' }} />
|
||||
<img
|
||||
src={form.logoDataUrl}
|
||||
alt={t('events.branding.logoAlt', 'Logo')}
|
||||
style={{ maxHeight: 80, maxWidth: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
<XStack space="$2">
|
||||
<CTAButton
|
||||
label={t('events.branding.replaceLogo', 'Replace logo')}
|
||||
@@ -524,10 +530,10 @@ export default function MobileBrandingPage() {
|
||||
paddingVertical="$2"
|
||||
borderRadius={12}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
borderColor={border}
|
||||
>
|
||||
<Trash2 size={16} color="#b91c1c" />
|
||||
<Text fontSize="$sm" color="#b91c1c" fontWeight="700">
|
||||
<Trash2 size={16} color={danger} />
|
||||
<Text fontSize="$sm" color={danger} fontWeight="700">
|
||||
{t('events.branding.removeLogo', 'Remove')}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -536,8 +542,8 @@ export default function MobileBrandingPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon size={28} color="#94a3b8" />
|
||||
<Text fontSize="$sm" color="#4b5563" textAlign="center">
|
||||
<ImageIcon size={28} color={subtle} />
|
||||
<Text fontSize="$sm" color={muted} textAlign="center">
|
||||
{t('events.branding.logoHint', 'Upload a logo to brand guest invites and QR posters.')}
|
||||
</Text>
|
||||
<Pressable onPress={() => document.getElementById('branding-logo-input')?.click()}>
|
||||
@@ -548,11 +554,11 @@ export default function MobileBrandingPage() {
|
||||
paddingVertical="$2.5"
|
||||
borderRadius={12}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
backgroundColor="white"
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
>
|
||||
<UploadCloud size={18} color="#007AFF" />
|
||||
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
|
||||
<UploadCloud size={18} color={primary} />
|
||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||
{t('events.branding.uploadLogo', 'Upload logo (max. 1 MB)')}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -600,13 +606,13 @@ export default function MobileBrandingPage() {
|
||||
borderRadius={14}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="white"
|
||||
backgroundColor={surface}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
borderColor={border}
|
||||
space="$2"
|
||||
>
|
||||
<RefreshCcw size={16} color="#111827" />
|
||||
<Text fontSize="$sm" color="#111827" fontWeight="700">
|
||||
<RefreshCcw size={16} color={textStrong} />
|
||||
<Text fontSize="$sm" color={textStrong} fontWeight="700">
|
||||
{t('events.branding.reset', 'Reset to Defaults')}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -624,7 +630,7 @@ export default function MobileBrandingPage() {
|
||||
{fontsLoading ? (
|
||||
Array.from({ length: 4 }).map((_, idx) => <SkeletonCard key={`font-sk-${idx}`} height={48} />)
|
||||
) : fonts.length === 0 ? (
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.branding.noFonts', 'Keine Schriftarten gefunden.')}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -641,17 +647,17 @@ export default function MobileBrandingPage() {
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
||||
<YStack>
|
||||
<Text fontSize="$sm" color="#111827" style={{ fontFamily: font.family }}>
|
||||
<Text fontSize="$sm" color={textStrong} style={{ fontFamily: font.family }}>
|
||||
{font.family}
|
||||
</Text>
|
||||
{font.variants?.length ? (
|
||||
<Text fontSize="$xs" color="#6b7280" style={{ fontFamily: font.family }}>
|
||||
<Text fontSize="$xs" color={muted} style={{ fontFamily: font.family }}>
|
||||
{font.variants.map((v) => v.style ?? v.weight ?? '').filter(Boolean).join(', ')}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
{form[fontField === 'heading' ? 'headingFont' : 'bodyFont'] === font.family ? (
|
||||
<Text fontSize="$xs" color="#007AFF">
|
||||
<Text fontSize="$xs" color={primary}>
|
||||
{t('common.active', 'Active')}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -677,8 +683,8 @@ function extractBranding(event: TenantEvent): BrandingForm {
|
||||
return typeof value === 'string' ? value : '';
|
||||
};
|
||||
return {
|
||||
primary: readColor('primary_color', '#007AFF'),
|
||||
accent: readColor('accent_color', '#5AD2F4'),
|
||||
primary: readColor('primary_color', ADMIN_COLORS.primary),
|
||||
accent: readColor('accent_color', ADMIN_COLORS.accent),
|
||||
headingFont: readText('heading_font'),
|
||||
bodyFont: readText('body_font'),
|
||||
logoDataUrl: readText('logo_data_url'),
|
||||
@@ -757,9 +763,10 @@ function renderName(name: TenantEvent['name']): string {
|
||||
}
|
||||
|
||||
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (next: string) => void }) {
|
||||
const { textStrong, muted, border, surface } = useAdminTheme();
|
||||
return (
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{label}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
@@ -767,9 +774,9 @@ function ColorField({ label, value, onChange }: { label: string; value: string;
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
style={{ width: 52, height: 52, borderRadius: 12, border: '1px solid #e5e7eb', background: 'white' }}
|
||||
style={{ width: 52, height: 52, borderRadius: 12, border: `1px solid ${border}`, background: surface }}
|
||||
/>
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{value}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -778,10 +785,11 @@ function ColorField({ label, value, onChange }: { label: string; value: string;
|
||||
}
|
||||
|
||||
function ColorSwatch({ color, label }: { color: string; label: string }) {
|
||||
const { border, muted } = useAdminTheme();
|
||||
return (
|
||||
<YStack alignItems="center" space="$1">
|
||||
<YStack width={40} height={40} borderRadius={12} borderWidth={1} borderColor="#e5e7eb" backgroundColor={color} />
|
||||
<Text fontSize="$xs" color="#4b5563">
|
||||
<YStack width={40} height={40} borderRadius={12} borderWidth={1} borderColor={border} backgroundColor={color} />
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{label}
|
||||
</Text>
|
||||
</YStack>
|
||||
@@ -803,20 +811,21 @@ function InputField({
|
||||
onPicker?: () => void;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { textStrong, border, surface, primary } = useAdminTheme();
|
||||
return (
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{label}
|
||||
</Text>
|
||||
<XStack
|
||||
alignItems="center"
|
||||
borderRadius={12}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
borderColor={border}
|
||||
paddingLeft="$3"
|
||||
paddingRight="$2"
|
||||
height={48}
|
||||
backgroundColor="white"
|
||||
backgroundColor={surface}
|
||||
space="$2"
|
||||
>
|
||||
{children ?? (
|
||||
@@ -838,7 +847,7 @@ function InputField({
|
||||
)}
|
||||
{onPicker ? (
|
||||
<Pressable onPress={onPicker}>
|
||||
<ChevronDown size={16} color="#007AFF" />
|
||||
<ChevronDown size={16} color={primary} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</XStack>
|
||||
@@ -865,13 +874,14 @@ function LabeledSlider({
|
||||
disabled?: boolean;
|
||||
suffix?: string;
|
||||
}) {
|
||||
const { textStrong, muted } = useAdminTheme();
|
||||
return (
|
||||
<YStack space="$1.5">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#4b5563">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{value}
|
||||
{suffix ? ` ${suffix}` : ''}
|
||||
</Text>
|
||||
@@ -899,6 +909,7 @@ function PositionGrid({
|
||||
onChange: (value: WatermarkPosition) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { textStrong, primary, border, accentSoft, surface } = useAdminTheme();
|
||||
const positions: WatermarkPosition[] = [
|
||||
'top-left',
|
||||
'top-center',
|
||||
@@ -913,7 +924,7 @@ function PositionGrid({
|
||||
|
||||
return (
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
Position
|
||||
</Text>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 8 }}>
|
||||
@@ -926,11 +937,11 @@ function PositionGrid({
|
||||
style={{
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
border: value === pos ? '2px solid #007AFF' : '1px solid #e5e7eb',
|
||||
background: value === pos ? '#eff6ff' : '#fff',
|
||||
border: value === pos ? `2px solid ${primary}` : `1px solid ${border}`,
|
||||
background: value === pos ? accentSoft : surface,
|
||||
}}
|
||||
>
|
||||
<Text fontSize="$xs" color="#0f172a" textAlign="center">
|
||||
<Text fontSize="$xs" color={textStrong} textAlign="center">
|
||||
{pos.replace('-', ' ')}
|
||||
</Text>
|
||||
</button>
|
||||
@@ -955,6 +966,7 @@ function WatermarkPreview({
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}) {
|
||||
const { border, muted, textStrong, overlay } = useAdminTheme();
|
||||
const width = 280;
|
||||
const height = 180;
|
||||
const wmWidth = Math.max(24, Math.round(width * Math.min(1, Math.max(0.05, scale))));
|
||||
@@ -1001,11 +1013,11 @@ function WatermarkPreview({
|
||||
height,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid #e5e7eb',
|
||||
background: 'linear-gradient(135deg, #e0f2fe, #c7d2fe)',
|
||||
border: `1px solid ${border}`,
|
||||
background: ADMIN_GRADIENTS.softCard,
|
||||
}}
|
||||
>
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, #0f172a66, transparent)' }} />
|
||||
<div style={{ position: 'absolute', inset: 0, background: `linear-gradient(180deg, ${overlay}, transparent)` }} />
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -1019,10 +1031,10 @@ function WatermarkPreview({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
opacity: Math.min(1, Math.max(0, opacity)),
|
||||
border: '1px dashed #64748b',
|
||||
border: `1px dashed ${muted}`,
|
||||
}}
|
||||
>
|
||||
<Droplets size={18} color="#0f172a" />
|
||||
<Droplets size={18} color={textStrong} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1030,11 +1042,12 @@ function WatermarkPreview({
|
||||
}
|
||||
|
||||
function InfoBadge({ icon, text, tone = 'info' }: { icon?: React.ReactNode; text: string; tone?: 'info' | 'danger' }) {
|
||||
const background = tone === 'danger' ? '#fef2f2' : '#f1f5f9';
|
||||
const color = tone === 'danger' ? '#991b1b' : '#0f172a';
|
||||
const { dangerBg, dangerText, surfaceMuted, textStrong, border } = useAdminTheme();
|
||||
const background = tone === 'danger' ? dangerBg : surfaceMuted;
|
||||
const color = tone === 'danger' ? dangerText : textStrong;
|
||||
|
||||
return (
|
||||
<MobileCard space="$2" backgroundColor={background} borderColor="#e2e8f0">
|
||||
<MobileCard space="$2" backgroundColor={background} borderColor={border}>
|
||||
<XStack space="$2" alignItems="center">
|
||||
{icon}
|
||||
<Text fontSize="$sm" color={color}>
|
||||
@@ -1046,6 +1059,7 @@ function InfoBadge({ icon, text, tone = 'info' }: { icon?: React.ReactNode; text
|
||||
}
|
||||
|
||||
function TabButton({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
||||
const { backdrop, surfaceMuted, border, surface } = useAdminTheme();
|
||||
return (
|
||||
<Pressable onPress={onPress} style={{ flex: 1 }}>
|
||||
<XStack
|
||||
@@ -1053,11 +1067,11 @@ function TabButton({ label, active, onPress }: { label: string; active: boolean;
|
||||
justifyContent="center"
|
||||
paddingVertical="$2.5"
|
||||
borderRadius={12}
|
||||
backgroundColor={active ? '#0f172a' : '#f1f5f9'}
|
||||
backgroundColor={active ? backdrop : surfaceMuted}
|
||||
borderWidth={1}
|
||||
borderColor={active ? '#0f172a' : '#e5e7eb'}
|
||||
borderColor={active ? backdrop : border}
|
||||
>
|
||||
<Text fontSize="$sm" color={active ? '#fff' : '#0f172a'} fontWeight="700">
|
||||
<Text fontSize="$sm" color={active ? surface : backdrop} fontWeight="700">
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
@@ -13,13 +13,13 @@ import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import { getEventStats, EventStats, TenantEvent, getEvents } from '../api';
|
||||
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
|
||||
import { useDevicePermissions } from './hooks/useDevicePermissions';
|
||||
import { useInstallPrompt } from './hooks/useInstallPrompt';
|
||||
import { getTourSeen, resolveTourStepKeys, setTourSeen, type TourStepKey } from './lib/mobileTour';
|
||||
import { trackOnboarding } from '../api';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
|
||||
|
||||
type DeviceSetupProps = {
|
||||
installPrompt: ReturnType<typeof useInstallPrompt>;
|
||||
@@ -43,13 +43,9 @@ export default function MobileDashboardPage() {
|
||||
const installPrompt = useInstallPrompt();
|
||||
const pushState = useAdminPushSubscription();
|
||||
const devicePermissions = useDevicePermissions();
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
|
||||
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
|
||||
const border = String(theme.borderColor?.val ?? '#334155');
|
||||
const surface = String(theme.surface?.val ?? '#ffffff');
|
||||
const accentSoft = String(theme.blue3?.val ?? '#e0f2fe');
|
||||
const accentText = String(theme.primary?.val ?? '#3b82f6');
|
||||
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
|
||||
const text = textStrong;
|
||||
const accentText = primary;
|
||||
|
||||
const { data: stats, isLoading: statsLoading } = useQuery<EventStats | null>({
|
||||
queryKey: ['mobile', 'dashboard', 'stats', activeEvent?.slug],
|
||||
@@ -246,9 +242,9 @@ export default function MobileDashboardPage() {
|
||||
borderRadius={14}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={String(theme.blue3?.val ?? '#e0f2fe')}
|
||||
backgroundColor={accentSoft}
|
||||
>
|
||||
<activeTourStep.icon size={18} color={String(theme.blue10?.val ?? '#2563eb')} />
|
||||
<activeTourStep.icon size={18} color={accentText} />
|
||||
</XStack>
|
||||
<Text fontSize="$lg" fontWeight="800" color={text}>
|
||||
{activeTourStep.title}
|
||||
@@ -384,13 +380,11 @@ export default function MobileDashboardPage() {
|
||||
|
||||
function DeviceSetupCard({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) {
|
||||
const { t } = useTranslation('management');
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color12?.val ?? theme.color?.val ?? '#0f172a');
|
||||
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#6b7280');
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const accent = String(theme.primary?.val ?? '#2563eb');
|
||||
const iconBg = String(theme.blue3?.val ?? '#e0f2fe');
|
||||
const iconColor = String(theme.blue10?.val ?? '#2563eb');
|
||||
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
|
||||
const text = textStrong;
|
||||
const accent = primary;
|
||||
const iconBg = accentSoft;
|
||||
const iconColor = primary;
|
||||
|
||||
const items: Array<{
|
||||
key: string;
|
||||
@@ -523,17 +517,13 @@ function DeviceSetupCard({ installPrompt, pushState, devicePermissions, onOpenSe
|
||||
function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onOpenSettings }: DeviceSetupProps) {
|
||||
const { t } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
|
||||
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
|
||||
const border = String(theme.borderColor?.val ?? '#cbd5e1');
|
||||
const accent = String(theme.primary?.val ?? '#2563eb');
|
||||
const accentSoft = String(theme.blue3?.val ?? '#e0f2fe');
|
||||
const accentStrong = String(theme.blue10?.val ?? '#1d4ed8');
|
||||
const stepBg = String(theme.gray2?.val ?? '#f8fafc');
|
||||
const stepBorder = String(theme.gray5?.val ?? '#e2e8f0');
|
||||
const supportBg = String(theme.gray2?.val ?? '#f8fafc');
|
||||
const supportBorder = String(theme.gray5?.val ?? '#e2e8f0');
|
||||
const { textStrong, muted, border, accentSoft, accentStrong, surfaceMuted, primary, shadow } = useAdminTheme();
|
||||
const text = textStrong;
|
||||
const accent = primary;
|
||||
const stepBg = surfaceMuted;
|
||||
const stepBorder = border;
|
||||
const supportBg = surfaceMuted;
|
||||
const supportBorder = border;
|
||||
|
||||
const steps = [
|
||||
t('mobileDashboard.emptyStepDetails', 'Add name & date'),
|
||||
@@ -653,7 +643,7 @@ function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onO
|
||||
borderWidth={1}
|
||||
borderColor={`${border}aa`}
|
||||
backgroundColor="rgba(255,255,255,0.6)"
|
||||
shadowColor="#0f172a"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.04}
|
||||
shadowRadius={10}
|
||||
shadowOffset={{ width: 0, height: 6 }}
|
||||
@@ -785,16 +775,15 @@ function FeaturedActions({
|
||||
onShowQr: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
|
||||
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
|
||||
const { textStrong, muted, subtle } = useAdminTheme();
|
||||
const text = textStrong;
|
||||
const cards = [
|
||||
{
|
||||
key: 'photos',
|
||||
label: t('mobileDashboard.photosLabel', 'Review photos'),
|
||||
desc: t('mobileDashboard.photosDesc', 'Moderate uploads and highlights'),
|
||||
icon: ImageIcon,
|
||||
color: '#0ea5e9',
|
||||
color: ADMIN_ACTION_COLORS.images,
|
||||
action: onReviewPhotos,
|
||||
},
|
||||
{
|
||||
@@ -804,7 +793,7 @@ function FeaturedActions({
|
||||
? t('mobileDashboard.tasksDesc', 'Assign and track progress')
|
||||
: t('mobileDashboard.tasksDisabledDesc', 'Guests do not see tasks (task mode off)'),
|
||||
icon: ListTodo,
|
||||
color: '#22c55e',
|
||||
color: ADMIN_ACTION_COLORS.tasks,
|
||||
action: onManageTasks,
|
||||
},
|
||||
{
|
||||
@@ -812,7 +801,7 @@ function FeaturedActions({
|
||||
label: t('mobileDashboard.qrLabel', 'Show / share QR code'),
|
||||
desc: t('mobileDashboard.qrDesc', 'Posters, cards, and links'),
|
||||
icon: QrCode,
|
||||
color: '#f59e0b',
|
||||
color: ADMIN_ACTION_COLORS.qr,
|
||||
action: onShowQr,
|
||||
},
|
||||
];
|
||||
@@ -834,7 +823,7 @@ function FeaturedActions({
|
||||
{card.desc}
|
||||
</Text>
|
||||
</YStack>
|
||||
<Text fontSize="$xl" color={String(theme.gray9?.val ?? '#94a3b8')}>
|
||||
<Text fontSize="$xl" color={subtle}>
|
||||
˃
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -859,41 +848,38 @@ function SecondaryGrid({
|
||||
onSettings: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
|
||||
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
|
||||
const border = String(theme.borderColor?.val ?? '#334155');
|
||||
const surface = String(theme.surface?.val ?? '#0b1220');
|
||||
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
|
||||
const text = textStrong;
|
||||
const brandingAllowed = isBrandingAllowed(event ?? null);
|
||||
const tiles = [
|
||||
{
|
||||
icon: Users,
|
||||
label: t('mobileDashboard.shortcutGuests', 'Guest management'),
|
||||
color: '#60a5fa',
|
||||
color: ADMIN_ACTION_COLORS.guests,
|
||||
action: onGuests,
|
||||
},
|
||||
{
|
||||
icon: QrCode,
|
||||
label: t('mobileDashboard.shortcutPrints', 'Print & poster downloads'),
|
||||
color: '#fbbf24',
|
||||
color: ADMIN_ACTION_COLORS.qr,
|
||||
action: onPrint,
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
label: t('mobileDashboard.shortcutInvites', 'Team / helper invites'),
|
||||
color: '#a855f7',
|
||||
color: ADMIN_ACTION_COLORS.invites,
|
||||
action: onInvites,
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
|
||||
color: '#10b981',
|
||||
color: ADMIN_ACTION_COLORS.success,
|
||||
action: onSettings,
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
label: t('mobileDashboard.shortcutBranding', 'Branding & moderation'),
|
||||
color: '#22d3ee',
|
||||
color: ADMIN_ACTION_COLORS.branding,
|
||||
action: brandingAllowed ? onSettings : undefined,
|
||||
disabled: !brandingAllowed,
|
||||
},
|
||||
@@ -905,7 +891,7 @@ function SecondaryGrid({
|
||||
{t('mobileDashboard.shortcutsTitle', 'Shortcuts')}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" space="$2">
|
||||
{tiles.map((tile) => (
|
||||
{tiles.map((tile, index) => (
|
||||
<ActionTile
|
||||
key={tile.label}
|
||||
icon={tile.icon}
|
||||
@@ -913,6 +899,7 @@ function SecondaryGrid({
|
||||
color={tile.color}
|
||||
onPress={tile.action}
|
||||
disabled={tile.disabled}
|
||||
delayMs={index * ADMIN_MOTION.tileStaggerMs}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
@@ -944,9 +931,8 @@ function KpiStrip({
|
||||
tasksEnabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
|
||||
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
|
||||
const { textStrong, muted } = useAdminTheme();
|
||||
const text = textStrong;
|
||||
if (!event) return null;
|
||||
|
||||
const kpis = [
|
||||
@@ -997,11 +983,8 @@ function KpiStrip({
|
||||
|
||||
function AlertsAndHints({ event, stats, tasksEnabled }: { event: TenantEvent | null; stats: EventStats | null | undefined; tasksEnabled: boolean }) {
|
||||
const { t } = useTranslation('management');
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
|
||||
const warningBg = String(theme.yellow3?.val ?? '#fff7ed');
|
||||
const warningBorder = String(theme.yellow6?.val ?? '#fed7aa');
|
||||
const warningText = String(theme.yellow11?.val ?? '#9a3412');
|
||||
const { textStrong, warningBg, warningBorder, warningText } = useAdminTheme();
|
||||
const text = textStrong;
|
||||
if (!event) return null;
|
||||
|
||||
const alerts: string[] = [];
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useEventContext } from '../context/EventContext';
|
||||
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
|
||||
import { isPastEvent } from './eventDate';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { ADMIN_ACTION_COLORS, ADMIN_MOTION, useAdminTheme } from './theme';
|
||||
|
||||
export default function MobileEventDetailPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
@@ -31,6 +32,7 @@ export default function MobileEventDetailPage() {
|
||||
const { events, activeEvent, selectEvent } = useEventContext();
|
||||
const [showEventPicker, setShowEventPicker] = React.useState(false);
|
||||
const back = useBackNavigation(adminPath('/mobile/events'));
|
||||
const { textStrong, text, muted, danger, accentSoft } = useAdminTheme();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) return;
|
||||
@@ -105,33 +107,33 @@ export default function MobileEventDetailPage() {
|
||||
headerActions={
|
||||
<XStack space="$3" alignItems="center">
|
||||
<HeaderActionButton onPress={() => navigate(adminPath('/mobile/settings'))} ariaLabel={t('mobileSettings.title', 'Settings')}>
|
||||
<Settings size={18} color="#0f172a" />
|
||||
<Settings size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
<HeaderActionButton onPress={() => navigate(0)} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
<RefreshCcw size={18} color="#0f172a" />
|
||||
<RefreshCcw size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
</XStack>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$lg" fontWeight="800" color="#111827">
|
||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||
{event ? renderName(event.name, t) : t('events.placeholders.untitled', 'Unbenanntes Event')}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<CalendarDays size={16} color="#6b7280" />
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
<CalendarDays size={16} color={muted} />
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{formatDate(event?.event_date, t)}
|
||||
</Text>
|
||||
<MapPin size={16} color="#6b7280" />
|
||||
<Text fontSize="$sm" color="#4b5563">
|
||||
<MapPin size={16} color={muted} />
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{resolveLocation(event, t)}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -148,13 +150,13 @@ export default function MobileEventDetailPage() {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#e2e8f0',
|
||||
backgroundColor: accentSoft,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
<Pencil size={18} color="#0f172a" />
|
||||
<Pencil size={18} color={textStrong} />
|
||||
</Pressable>
|
||||
</MobileCard>
|
||||
|
||||
@@ -183,7 +185,7 @@ export default function MobileEventDetailPage() {
|
||||
>
|
||||
<YStack space="$2">
|
||||
{events.length === 0 ? (
|
||||
<Text fontSize={12.5} color="#4b5563">
|
||||
<Text fontSize={12.5} color={muted}>
|
||||
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
|
||||
</Text>
|
||||
) : (
|
||||
@@ -198,12 +200,12 @@ export default function MobileEventDetailPage() {
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
|
||||
<YStack space="$1">
|
||||
<Text fontSize={13} fontWeight="700" color="#111827">
|
||||
<Text fontSize={13} fontWeight="700" color={textStrong}>
|
||||
{renderName(ev.name, t)}
|
||||
</Text>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<CalendarDays size={14} color="#6b7280" />
|
||||
<Text fontSize={12} color="#4b5563">
|
||||
<CalendarDays size={14} color={muted} />
|
||||
<Text fontSize={12} color={muted}>
|
||||
{formatDate(ev.event_date, t)}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -219,7 +221,7 @@ export default function MobileEventDetailPage() {
|
||||
</MobileSheet>
|
||||
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.detail.managementTitle', 'Event Management')}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" space="$2">
|
||||
@@ -230,55 +232,63 @@ export default function MobileEventDetailPage() {
|
||||
? t('events.quick.tasks', 'Tasks & Checklists')
|
||||
: `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`
|
||||
}
|
||||
color="#60a5fa"
|
||||
color={ADMIN_ACTION_COLORS.tasks}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/tasks`))}
|
||||
delayMs={0}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={QrCode}
|
||||
label={t('events.quick.qr', 'QR Code Layouts')}
|
||||
color="#fbbf24"
|
||||
color={ADMIN_ACTION_COLORS.qr}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/qr`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Image}
|
||||
label={t('events.quick.images', 'Image Management')}
|
||||
color="#a855f7"
|
||||
color={ADMIN_ACTION_COLORS.images}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photos`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 2}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Users}
|
||||
label={t('events.quick.guests', 'Guest Management')}
|
||||
color="#4ade80"
|
||||
color={ADMIN_ACTION_COLORS.guests}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/members`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 3}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Megaphone}
|
||||
label={t('events.quick.guestMessages', 'Guest messages')}
|
||||
color="#fb923c"
|
||||
color={ADMIN_ACTION_COLORS.guestMessages}
|
||||
onPress={() => slug && navigate(ADMIN_EVENT_GUEST_NOTIFICATIONS_PATH(slug))}
|
||||
disabled={!slug}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 4}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Layout}
|
||||
label={t('events.quick.branding', 'Branding & Theme')}
|
||||
color="#fb7185"
|
||||
color={ADMIN_ACTION_COLORS.branding}
|
||||
onPress={
|
||||
brandingAllowed ? () => navigate(adminPath(`/mobile/events/${slug ?? ''}/branding`)) : undefined
|
||||
}
|
||||
disabled={!brandingAllowed}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 5}
|
||||
/>
|
||||
<ActionTile
|
||||
icon={Camera}
|
||||
label={t('events.quick.photobooth', 'Photobooth')}
|
||||
color="#38bdf8"
|
||||
color={ADMIN_ACTION_COLORS.photobooth}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/photobooth`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 6}
|
||||
/>
|
||||
{isPastEvent(event?.event_date) ? (
|
||||
<ActionTile
|
||||
icon={Sparkles}
|
||||
label={t('events.quick.recap', 'Recap & Archive')}
|
||||
color="#f59e0b"
|
||||
color={ADMIN_ACTION_COLORS.recap}
|
||||
onPress={() => navigate(adminPath(`/mobile/events/${slug ?? ''}/recap`))}
|
||||
delayMs={ADMIN_MOTION.tileStaggerMs * 7}
|
||||
/>
|
||||
) : null}
|
||||
</XStack>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { isAuthError } from '../auth/tokens';
|
||||
import { getApiValidationMessage, isApiError } from '../lib/apiError';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
type FormState = {
|
||||
name: string;
|
||||
@@ -34,6 +35,7 @@ export default function MobileEventFormPage() {
|
||||
const isEdit = Boolean(slug);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation(['management', 'common']);
|
||||
const { text, muted, subtle, danger, border, surfaceMuted } = useAdminTheme();
|
||||
|
||||
const [form, setForm] = React.useState<FormState>({
|
||||
name: '',
|
||||
@@ -204,7 +206,7 @@ export default function MobileEventFormPage() {
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
@@ -228,15 +230,15 @@ export default function MobileEventFormPage() {
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<CalendarDays size={16} color="#9ca3af" />
|
||||
<CalendarDays size={16} color={subtle} />
|
||||
</XStack>
|
||||
</MobileField>
|
||||
|
||||
<MobileField label={t('eventForm.fields.type.label', 'Event type')}>
|
||||
{typesLoading ? (
|
||||
<Text fontSize="$sm" color="#6b7280">{t('eventForm.fields.type.loading', 'Loading event types…')}</Text>
|
||||
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.type.loading', 'Loading event types…')}</Text>
|
||||
) : eventTypes.length === 0 ? (
|
||||
<Text fontSize="$sm" color="#6b7280">{t('eventForm.fields.type.empty', 'No event types available yet. Please add one in the admin area.')}</Text>
|
||||
<Text fontSize="$sm" color={muted}>{t('eventForm.fields.type.empty', 'No event types available yet. Please add one in the admin area.')}</Text>
|
||||
) : (
|
||||
<MobileSelect
|
||||
value={form.eventTypeId ?? ''}
|
||||
@@ -269,7 +271,7 @@ export default function MobileEventFormPage() {
|
||||
placeholder={t('eventForm.fields.location.placeholder', 'Location')}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<MapPin size={16} color="#9ca3af" />
|
||||
<MapPin size={16} color={subtle} />
|
||||
</XStack>
|
||||
</MobileField>
|
||||
|
||||
@@ -285,11 +287,11 @@ export default function MobileEventFormPage() {
|
||||
>
|
||||
<Switch.Thumb />
|
||||
</Switch>
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{form.published ? t('common:states.enabled', 'Enabled') : t('common:states.disabled', 'Disabled')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color="#6b7280">{t('eventForm.fields.publish.help', 'Enable if guests should see the event right away. You can change the status later.')}</Text>
|
||||
<Text fontSize="$xs" color={muted}>{t('eventForm.fields.publish.help', 'Enable if guests should see the event right away. You can change the status later.')}</Text>
|
||||
</MobileField>
|
||||
|
||||
<MobileField label={t('eventForm.fields.tasksMode.label', 'Tasks & challenges')}>
|
||||
@@ -304,13 +306,13 @@ export default function MobileEventFormPage() {
|
||||
>
|
||||
<Switch.Thumb />
|
||||
</Switch>
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{form.tasksEnabled
|
||||
? t('common:states.enabled', 'Enabled')
|
||||
: t('common:states.disabled', 'Disabled')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{form.tasksEnabled
|
||||
? t(
|
||||
'eventForm.fields.tasksMode.helpOn',
|
||||
@@ -335,13 +337,13 @@ export default function MobileEventFormPage() {
|
||||
>
|
||||
<Switch.Thumb />
|
||||
</Switch>
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{form.autoApproveUploads
|
||||
? t('common:states.enabled', 'Enabled')
|
||||
: t('common:states.disabled', 'Disabled')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{form.autoApproveUploads
|
||||
? t(
|
||||
'eventForm.fields.uploadVisibility.helpOn',
|
||||
@@ -364,8 +366,8 @@ export default function MobileEventFormPage() {
|
||||
...inputStyle,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
border: '1px solid #e5e7eb',
|
||||
background: '#f1f5f9',
|
||||
border: `1px solid ${border}`,
|
||||
background: surfaceMuted,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { RefreshCcw, Users, User } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
@@ -24,6 +23,7 @@ import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { adminPath } from '../constants';
|
||||
import { formatGuestMessageDate } from './guestMessages';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
type FormState = {
|
||||
title: string;
|
||||
@@ -40,7 +40,7 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
const theme = useTheme();
|
||||
const { textStrong, text, muted, border, danger } = useAdminTheme();
|
||||
const { activeEvent, selectEvent } = useEventContext();
|
||||
const slug = slugParam ?? activeEvent?.slug ?? null;
|
||||
const [history, setHistory] = React.useState<GuestNotificationSummary[]>([]);
|
||||
@@ -181,7 +181,7 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
}
|
||||
|
||||
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
|
||||
const mutedText = String(theme.gray?.val ?? '#6b7280');
|
||||
const mutedText = muted;
|
||||
|
||||
return (
|
||||
<MobileShell
|
||||
@@ -191,13 +191,13 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => loadHistory()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
<RefreshCcw size={18} color={String(theme.color?.val ?? '#111827')} />
|
||||
<RefreshCcw size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color={String(theme.red10?.val ?? '#b91c1c')}>
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
@@ -205,10 +205,10 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
|
||||
<div ref={formRef}>
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color={String(theme.color?.val ?? '#111827')}>
|
||||
{t('guestMessages.composeTitle', 'Send a message')}
|
||||
</Text>
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('guestMessages.composeTitle', 'Send a message')}
|
||||
</Text>
|
||||
<YStack space="$2">
|
||||
<MobileField label={t('guestMessages.form.title', 'Title')}>
|
||||
<MobileInput
|
||||
type="text"
|
||||
@@ -270,7 +270,7 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
max={2880}
|
||||
value={form.expires_in_minutes}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, expires_in_minutes: e.target.value }))}
|
||||
placeholder="60"
|
||||
placeholder={t('guestMessages.form.expiresPlaceholder', '60')}
|
||||
/>
|
||||
</MobileField>
|
||||
<MobileField label={t('guestMessages.form.priority', 'Priority')}>
|
||||
@@ -297,17 +297,17 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
{t('guestMessages.form.validation', 'Add a title and message. Target guests need an identifier.')}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
</div>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$md" fontWeight="800" color={String(theme.color?.val ?? '#111827')}>
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('guestMessages.historyTitle', 'Recent messages')}
|
||||
</Text>
|
||||
<Pressable onPress={() => loadHistory()}>
|
||||
<RefreshCcw size={18} color={String(theme.color?.val ?? '#111827')} />
|
||||
<RefreshCcw size={18} color={textStrong} />
|
||||
</Pressable>
|
||||
</XStack>
|
||||
|
||||
@@ -319,7 +319,7 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
</YStack>
|
||||
) : history.length === 0 ? (
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color={String(theme.color?.val ?? '#111827')}>
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('guestMessages.emptyTitle', 'Send your first guest message')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={mutedText}>
|
||||
@@ -334,9 +334,9 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
{history.map((item) => (
|
||||
<MobileCard key={item.id} space="$2" borderColor={String(theme.borderColor?.val ?? '#e5e7eb')}>
|
||||
<MobileCard key={item.id} space="$2" borderColor={border}>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$sm" fontWeight="800" color={String(theme.color?.val ?? '#111827')}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
|
||||
{item.title || t('guestMessages.history.untitled', 'Untitled')}
|
||||
</Text>
|
||||
<XStack space="$1.5" alignItems="center">
|
||||
@@ -350,7 +350,7 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={String(theme.color?.val ?? '#111827')}>
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{item.body ?? t('guestMessages.history.noBody', 'No body provided.')}
|
||||
</Text>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
@@ -359,8 +359,8 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
{item.target_identifier ? (
|
||||
<PillBadge tone="muted">
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<User size={12} color={String(theme.gray10?.val ?? '#6b7280')} />
|
||||
<Text fontSize="$xs" fontWeight="700" color={String(theme.gray10?.val ?? '#6b7280')}>
|
||||
<User size={12} color={muted} />
|
||||
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||
{item.target_identifier}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -368,8 +368,8 @@ export default function MobileEventGuestNotificationsPage() {
|
||||
) : (
|
||||
<PillBadge tone="muted">
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<Users size={12} color={String(theme.gray10?.val ?? '#6b7280')} />
|
||||
<Text fontSize="$xs" fontWeight="700" color={String(theme.gray10?.val ?? '#6b7280')}>
|
||||
<Users size={12} color={muted} />
|
||||
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||
{t('guestMessages.audience.all', 'All guests')}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UserPlus, Trash2, Copy, RefreshCcw } from 'lucide-react';
|
||||
import { Trash2, Copy, RefreshCcw } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
@@ -15,21 +15,24 @@ import toast from 'react-hot-toast';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
import { adminPath } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
export default function MobileEventMembersPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const slug = slugParam ?? null;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, text, muted, border, primary, danger } = useAdminTheme();
|
||||
|
||||
const [members, setMembers] = React.useState<EventMember[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [invite, setInvite] = React.useState({ name: '', email: '', role: 'member' as EventMember['role'] });
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [inviteLink, setInviteLink] = React.useState<string | null>(null);
|
||||
const emailInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [search, setSearch] = React.useState('');
|
||||
const [statusFilter, setStatusFilter] = React.useState<'all' | 'pending' | 'active' | 'invited'>('all');
|
||||
const [roleFilter, setRoleFilter] = React.useState<'all' | 'member' | 'tenant_admin'>('all');
|
||||
const [confirmRemove, setConfirmRemove] = React.useState<EventMember | null>(null);
|
||||
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
||||
|
||||
@@ -40,12 +43,6 @@ export default function MobileEventMembersPage() {
|
||||
try {
|
||||
const result = await getEventMembers(slug, 1);
|
||||
setMembers(result.data);
|
||||
if (result.data.length) {
|
||||
const pending = result.data.find((m) => m.status === 'pending' && m.permissions?.includes('invite_link'));
|
||||
if (pending?.email) {
|
||||
setInviteLink(`mailto:${pending.email}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Mitglieder konnten nicht geladen werden.')));
|
||||
@@ -59,13 +56,58 @@ export default function MobileEventMembersPage() {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const normalizedEmail = invite.email.trim();
|
||||
const isEmailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail);
|
||||
const canInvite = Boolean(normalizedEmail) && isEmailValid && !saving;
|
||||
|
||||
const filteredMembers = members.filter((member) => {
|
||||
if (statusFilter !== 'all') {
|
||||
const currentStatus = member.status ?? 'pending';
|
||||
if (currentStatus !== statusFilter) return false;
|
||||
}
|
||||
if (roleFilter !== 'all' && member.role !== roleFilter) return false;
|
||||
if (!search.trim()) return true;
|
||||
const hay = `${member.name ?? ''} ${member.email ?? ''}`.toLowerCase();
|
||||
return hay.includes(search.toLowerCase());
|
||||
});
|
||||
|
||||
const statusOptions = [
|
||||
{ key: 'all', label: t('events.members.filters.statusAll', 'All statuses') },
|
||||
{ key: 'pending', label: t('events.members.filters.statusPending', 'Pending') },
|
||||
{ key: 'active', label: t('events.members.filters.statusActive', 'Active') },
|
||||
{ key: 'invited', label: t('events.members.filters.statusInvited', 'Invited') },
|
||||
] as const;
|
||||
|
||||
const roleOptions = [
|
||||
{ key: 'all', label: t('events.members.filters.roleAll', 'All roles') },
|
||||
{ key: 'tenant_admin', label: t('events.members.filters.roleAdmin', 'Admins') },
|
||||
{ key: 'member', label: t('events.members.filters.roleMember', 'Members') },
|
||||
] as const;
|
||||
|
||||
const resolveStatus = (status?: string) => {
|
||||
switch (status ?? 'pending') {
|
||||
case 'active':
|
||||
return { label: t('events.members.statuses.active', 'Active'), tone: 'success' as const };
|
||||
case 'invited':
|
||||
return { label: t('events.members.statuses.invited', 'Invited'), tone: 'warning' as const };
|
||||
case 'pending':
|
||||
return { label: t('events.members.statuses.pending', 'Pending'), tone: 'warning' as const };
|
||||
default:
|
||||
return { label: t('events.members.statuses.unknown', 'Unknown'), tone: 'muted' as const };
|
||||
}
|
||||
};
|
||||
|
||||
async function handleInvite() {
|
||||
if (!slug || !invite.email.trim()) return;
|
||||
if (!slug || !normalizedEmail) return;
|
||||
if (!isEmailValid) {
|
||||
toast.error(t('events.members.emailInvalid', 'Please enter a valid email address.'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const member = await inviteEventMember(slug, {
|
||||
email: invite.email.trim(),
|
||||
email: normalizedEmail,
|
||||
name: invite.name.trim() || undefined,
|
||||
role: invite.role,
|
||||
});
|
||||
@@ -104,20 +146,20 @@ export default function MobileEventMembersPage() {
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
<RefreshCcw size={18} color="#0f172a" />
|
||||
<RefreshCcw size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.members.inviteTitle', 'Invite Member')}
|
||||
</Text>
|
||||
<YStack space="$2">
|
||||
@@ -126,7 +168,7 @@ export default function MobileEventMembersPage() {
|
||||
type="text"
|
||||
value={invite.name}
|
||||
onChange={(e) => setInvite((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Alex Example"
|
||||
placeholder={t('events.members.namePlaceholder', 'Alex Example')}
|
||||
/>
|
||||
</MobileField>
|
||||
<MobileField label={t('events.members.email', 'Email')}>
|
||||
@@ -134,7 +176,7 @@ export default function MobileEventMembersPage() {
|
||||
type="email"
|
||||
value={invite.email}
|
||||
onChange={(e) => setInvite((prev) => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="alex@example.com"
|
||||
placeholder={t('events.members.emailPlaceholder', 'alex@example.com')}
|
||||
ref={emailInputRef}
|
||||
/>
|
||||
</MobileField>
|
||||
@@ -147,46 +189,91 @@ export default function MobileEventMembersPage() {
|
||||
<option value="tenant_admin">{t('events.members.roleAdmin', 'Admin')}</option>
|
||||
</MobileSelect>
|
||||
</MobileField>
|
||||
<CTAButton label={saving ? t('common.saving', 'Saving...') : t('events.members.invite', 'Send Invite')} onPress={() => handleInvite()} />
|
||||
<CTAButton
|
||||
label={saving ? t('common.saving', 'Saving...') : t('events.members.invite', 'Send Invite')}
|
||||
onPress={() => handleInvite()}
|
||||
disabled={!canInvite}
|
||||
/>
|
||||
{saving ? (
|
||||
<Text fontSize="$xs" color="#4b5563">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('common.processing', 'Processing...')}
|
||||
</Text>
|
||||
) : null}
|
||||
{!isEmailValid && invite.email.trim().length > 0 ? (
|
||||
<Text fontSize="$xs" color={danger}>
|
||||
{t('events.members.emailInvalid', 'Please enter a valid email address.')}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
<MobileInput
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('events.members.search', 'Search members')}
|
||||
compact
|
||||
/>
|
||||
{members.length > 0 || search.trim().length > 0 ? (
|
||||
<MobileInput
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('events.members.search', 'Search members')}
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{members.length > 0 ? (
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||
{t('events.members.filters.statusLabel', 'Status')}
|
||||
</Text>
|
||||
<XStack space="$2" flexWrap="wrap">
|
||||
{statusOptions.map((option) => {
|
||||
const isActive = statusFilter === option.key;
|
||||
return (
|
||||
<Pressable key={option.key} onPress={() => setStatusFilter(option.key)}>
|
||||
<XStack
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$1.5"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
borderColor={isActive ? primary : border}
|
||||
backgroundColor={isActive ? primary : 'transparent'}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={isActive ? 'white' : text}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
<Text fontSize="$xs" fontWeight="700" color={muted}>
|
||||
{t('events.members.filters.roleLabel', 'Role')}
|
||||
</Text>
|
||||
<XStack space="$2" flexWrap="wrap">
|
||||
{roleOptions.map((option) => {
|
||||
const isActive = roleFilter === option.key;
|
||||
return (
|
||||
<Pressable key={option.key} onPress={() => setRoleFilter(option.key)}>
|
||||
<XStack
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$1.5"
|
||||
borderRadius={999}
|
||||
borderWidth={1}
|
||||
borderColor={isActive ? primary : border}
|
||||
backgroundColor={isActive ? primary : 'transparent'}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={isActive ? 'white' : text}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$3">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.members.listTitle', 'Team & Guests')}
|
||||
</Text>
|
||||
{inviteLink ? (
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
toast.success(t('events.members.copyInvite', 'Einladungslink kopiert'));
|
||||
} catch {
|
||||
toast.error(t('events.members.copyInviteFailed', 'Kopieren nicht möglich'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" space="$2" marginBottom="$2">
|
||||
<Copy size={16} color="#007AFF" />
|
||||
<Text fontSize="$sm" color="#007AFF">
|
||||
{t('events.members.copyInviteLabel', 'Invite Link kopieren')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<YStack space="$2">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
@@ -195,72 +282,86 @@ export default function MobileEventMembersPage() {
|
||||
</YStack>
|
||||
) : members.length === 0 ? (
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.members.emptyTitle', 'Invite your team')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#4b5563">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.members.emptyBody', 'Send the first invite so helpers can access the event.')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
label={t('events.members.emptyAction', 'Send first invite')}
|
||||
onPress={() => emailInputRef.current?.focus()}
|
||||
fullWidth={false}
|
||||
/>
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack space="$2">
|
||||
{members
|
||||
.filter((member) => {
|
||||
if (!search.trim()) return true;
|
||||
const hay = `${member.name ?? ''} ${member.email ?? ''}`.toLowerCase();
|
||||
return hay.includes(search.toLowerCase());
|
||||
})
|
||||
.map((member) => (
|
||||
<MobileCard key={member.id} padding="$3" borderColor="#e5e7eb">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{member.name || member.email || 'Gast'}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
{member.email ?? ''}
|
||||
</Text>
|
||||
<XStack space="$1.5" alignItems="center">
|
||||
<PillBadge tone={member.status === 'pending' ? 'warning' : 'muted'}>
|
||||
{member.status ?? 'pending'}
|
||||
</PillBadge>
|
||||
<PillBadge tone={member.role === 'tenant_admin' ? 'success' : 'muted'}>
|
||||
{member.role === 'tenant_admin'
|
||||
? t('events.members.admin', 'Admin')
|
||||
: t('events.members.member', 'Member')}
|
||||
</PillBadge>
|
||||
{filteredMembers.length === 0 ? (
|
||||
<YStack space="$1.5" padding="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.members.emptyFilteredTitle', 'No matching members')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.members.emptyFilteredBody', 'Adjust your search or filters to see members.')}
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setSearch('');
|
||||
setStatusFilter('all');
|
||||
setRoleFilter('all');
|
||||
}}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={primary}>
|
||||
{t('events.members.clearFilters', 'Clear filters')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</YStack>
|
||||
) : (
|
||||
filteredMembers.map((member) => {
|
||||
const statusInfo = resolveStatus(member.status);
|
||||
return (
|
||||
<MobileCard key={member.id} padding="$3" borderColor={border}>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{member.name || member.email || t('events.members.fallbackName', 'Guest')}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{member.email ?? ''}
|
||||
</Text>
|
||||
<XStack space="$1.5" alignItems="center">
|
||||
<PillBadge tone={statusInfo.tone}>
|
||||
{statusInfo.label}
|
||||
</PillBadge>
|
||||
<PillBadge tone={member.role === 'tenant_admin' ? 'success' : 'muted'}>
|
||||
{member.role === 'tenant_admin'
|
||||
? t('events.members.admin', 'Admin')
|
||||
: t('events.members.member', 'Member')}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
</YStack>
|
||||
<XStack space="$2">
|
||||
<Pressable
|
||||
aria-label={t('events.members.copyEmailLabel', 'Copy email')}
|
||||
onPress={async () => {
|
||||
if (!member.email) {
|
||||
toast.error(t('events.members.copyEmailFailed', 'Kopieren nicht möglich'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(member.email);
|
||||
toast.success(t('events.members.copyEmail', 'E-Mail kopiert'));
|
||||
} catch {
|
||||
toast.error(t('events.members.copyEmailFailed', 'Kopieren nicht möglich'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy size={16} color={muted} />
|
||||
</Pressable>
|
||||
<Pressable aria-label={t('events.members.remove', 'Remove')} onPress={() => setConfirmRemove(member)}>
|
||||
<Trash2 size={16} color={danger} />
|
||||
</Pressable>
|
||||
</XStack>
|
||||
</YStack>
|
||||
<XStack space="$2">
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
const link = inviteLink || (member.email ? `mailto:${member.email}` : null);
|
||||
if (!link) {
|
||||
toast.error(t('events.members.copyInviteFailed', 'Kopieren nicht möglich'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
toast.success(t('events.members.copyInvite', 'Einladungslink kopiert'));
|
||||
} catch {
|
||||
toast.error(t('events.members.copyInviteFailed', 'Kopieren nicht möglich'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy size={16} color="#6b7280" />
|
||||
</Pressable>
|
||||
<Pressable onPress={() => setConfirmRemove(member)}>
|
||||
<Trash2 size={16} color="#ef4444" />
|
||||
</Pressable>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
))}
|
||||
</MobileCard>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</YStack>
|
||||
)}
|
||||
</MobileCard>
|
||||
@@ -282,10 +383,10 @@ export default function MobileEventMembersPage() {
|
||||
}
|
||||
bottomOffsetPx={120}
|
||||
>
|
||||
<Text fontSize={12.5} color="#4b5563">
|
||||
<Text fontSize={12.5} color={muted}>
|
||||
{t('events.members.removeHint', 'Dieses Mitglied verliert den Zugang zum Event.')}
|
||||
</Text>
|
||||
<Text fontSize={13} fontWeight="700" color="#111827">
|
||||
<Text fontSize={13} fontWeight="700" color={textStrong}>
|
||||
{confirmRemove?.name || confirmRemove?.email}
|
||||
</Text>
|
||||
</MobileSheet>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Switch } from '@tamagui/switch';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
|
||||
import {
|
||||
@@ -24,17 +23,14 @@ import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
||||
import toast from 'react-hot-toast';
|
||||
import { adminPath } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
export default function MobileEventPhotoboothPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const slug = slugParam ?? null;
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color?.val ?? '#111827');
|
||||
const muted = String(theme.gray?.val ?? '#4b5563');
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const surface = String(theme.surface?.val ?? '#ffffff');
|
||||
const { text, muted, border, surface, danger } = useAdminTheme();
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [status, setStatus] = React.useState<PhotoboothStatus | null>(null);
|
||||
@@ -177,7 +173,7 @@ export default function MobileEventPhotoboothPage() {
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
@@ -383,13 +379,14 @@ export default function MobileEventPhotoboothPage() {
|
||||
|
||||
function CredentialRow({ label, value, border, masked }: { label: string; value: string; border: string; masked?: boolean }) {
|
||||
const { t } = useTranslation('management');
|
||||
const { muted, text } = useAdminTheme();
|
||||
return (
|
||||
<XStack alignItems="center" justifyContent="space-between" borderWidth={1} borderColor={border} borderRadius="$3" padding="$2">
|
||||
<YStack>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{masked ? '••••••••' : value}
|
||||
</Text>
|
||||
</YStack>
|
||||
@@ -403,22 +400,23 @@ function CredentialRow({ label, value, border, masked }: { label: string; value:
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy size={16} color="#6b7280" />
|
||||
<Copy size={16} color={muted} />
|
||||
</Pressable>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
|
||||
const { text } = useAdminTheme();
|
||||
return (
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<XStack alignItems="center" space="$2">
|
||||
{icon}
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{value}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
@@ -29,7 +29,7 @@ import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { useAdminTheme } from './theme';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { adminPath } from '../constants';
|
||||
import { scopeDefaults, selectAddonKeyForScope } from './addons';
|
||||
@@ -88,15 +88,9 @@ export default function MobileEventPhotosPage() {
|
||||
const [queuedActions, setQueuedActions] = React.useState<PhotoModerationAction[]>(() => loadPhotoQueue());
|
||||
const online = useOnlineStatus();
|
||||
const syncingQueueRef = React.useRef(false);
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color?.val ?? '#111827');
|
||||
const muted = String(theme.gray?.val ?? '#4b5563');
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const infoBg = String(theme.blue3?.val ?? '#e8f1ff');
|
||||
const infoBorder = String(theme.blue6?.val ?? '#bfdbfe');
|
||||
const danger = String(theme.red10?.val ?? '#b91c1c');
|
||||
const surface = String(theme.surface?.val ?? '#ffffff');
|
||||
const backdrop = String(theme.gray12?.val ?? '#0f172a');
|
||||
const { text, muted, border, accentSoft, accent, danger, surface, backdrop, primary } = useAdminTheme();
|
||||
const infoBg = accentSoft;
|
||||
const infoBorder = accent;
|
||||
|
||||
const basePhotosPath = slug ? adminPath(`/mobile/events/${slug}/photos`) : adminPath('/mobile/events');
|
||||
const photoQuery = React.useMemo(() => {
|
||||
@@ -858,9 +852,9 @@ export default function MobileEventPhotosPage() {
|
||||
borderRadius={999}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={isSelected ? String(theme.primary?.val ?? '#007AFF') : 'rgba(255,255,255,0.85)'}
|
||||
backgroundColor={isSelected ? primary : 'rgba(255,255,255,0.85)'}
|
||||
borderWidth={1}
|
||||
borderColor={isSelected ? String(theme.primary?.val ?? '#007AFF') : border}
|
||||
borderColor={isSelected ? primary : border}
|
||||
>
|
||||
{isSelected ? <Check size={14} color="white" /> : null}
|
||||
</XStack>
|
||||
@@ -896,7 +890,7 @@ export default function MobileEventPhotosPage() {
|
||||
backgroundColor={surface}
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
shadowColor="#0f172a"
|
||||
shadowColor={backdrop}
|
||||
shadowOpacity={0.18}
|
||||
shadowRadius={16}
|
||||
shadowOffset={{ width: 0, height: 8 }}
|
||||
@@ -908,7 +902,7 @@ export default function MobileEventPhotosPage() {
|
||||
{t('mobilePhotos.selectedCount', '{{count}} selected', { count: selectedIds.length })}
|
||||
</Text>
|
||||
<Pressable onPress={() => clearSelection()}>
|
||||
<Text fontSize="$sm" color={String(theme.primary?.val ?? '#007AFF')} fontWeight="700">
|
||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||
{t('common.clear', 'Clear')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -1167,7 +1161,7 @@ type SwipeActionConfig = {
|
||||
|
||||
function PhotoSwipeCard({ photo, disabled = false, onOpen, onModerate, children }: PhotoSwipeCardProps) {
|
||||
const { t } = useTranslation('management');
|
||||
const theme = useTheme();
|
||||
const { successBg, successText, dangerBg, dangerText, infoBg, infoText } = useAdminTheme();
|
||||
const controls = useAnimationControls();
|
||||
const dragged = React.useRef(false);
|
||||
const leftAction = resolvePhotoSwipeAction(photo, 'left');
|
||||
@@ -1181,23 +1175,23 @@ function PhotoSwipeCard({ photo, disabled = false, onOpen, onModerate, children
|
||||
if (action === 'approve') {
|
||||
return {
|
||||
label: t('photos.actions.approve', 'Approve'),
|
||||
bg: String(theme.green3?.val ?? '#dcfce7'),
|
||||
text: String(theme.green11?.val ?? '#166534'),
|
||||
bg: successBg,
|
||||
text: successText,
|
||||
icon: Check,
|
||||
};
|
||||
}
|
||||
if (action === 'hide') {
|
||||
return {
|
||||
label: t('photos.actions.hide', 'Hide'),
|
||||
bg: String(theme.red3?.val ?? '#fee2e2'),
|
||||
text: String(theme.red11?.val ?? '#b91c1c'),
|
||||
bg: dangerBg,
|
||||
text: dangerText,
|
||||
icon: EyeOff,
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: t('photos.actions.show', 'Show'),
|
||||
bg: String(theme.blue3?.val ?? '#dbeafe'),
|
||||
text: String(theme.blue11?.val ?? '#1d4ed8'),
|
||||
bg: infoBg,
|
||||
text: infoText,
|
||||
icon: Eye,
|
||||
};
|
||||
};
|
||||
@@ -1509,10 +1503,11 @@ function MobileAddonsPicker({
|
||||
}
|
||||
|
||||
function EventAddonList({ addons, textColor, mutedColor }: { addons: EventAddonSummary[]; textColor: string; mutedColor: string }) {
|
||||
const { border } = useAdminTheme();
|
||||
return (
|
||||
<YStack space="$2">
|
||||
{addons.map((addon) => (
|
||||
<MobileCard key={addon.id} borderColor="#e5e7eb" space="$1.5">
|
||||
<MobileCard key={addon.id} borderColor={border} space="$1.5">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textColor}>
|
||||
{addon.label ?? addon.key}
|
||||
|
||||
@@ -28,12 +28,14 @@ import { adminPath } from '../constants';
|
||||
import { selectAddonKeyForScope } from './addons';
|
||||
import { LegalConsentSheet } from './components/LegalConsentSheet';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
export default function MobileEventRecapPage() {
|
||||
const { slug } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, text, muted, subtle, danger, border, primary, accentSoft } = useAdminTheme();
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [stats, setStats] = React.useState<EventStats | null>(null);
|
||||
@@ -92,7 +94,7 @@ export default function MobileEventRecapPage() {
|
||||
return (
|
||||
<MobileShell activeTab="home" title={t('events.errors.notFoundTitle', 'Event nicht gefunden')} onBack={back}>
|
||||
<MobileCard>
|
||||
<Text color="#b91c1c">{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</Text>
|
||||
<Text color={danger}>{t('events.errors.notFoundBody', 'Kehre zur Eventliste zurück und wähle dort ein Event aus.')}</Text>
|
||||
</MobileCard>
|
||||
</MobileShell>
|
||||
);
|
||||
@@ -202,13 +204,13 @@ export default function MobileEventRecapPage() {
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
<RefreshCcw size={18} color="#0f172a" />
|
||||
<RefreshCcw size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text color="#b91c1c">{error}</Text>
|
||||
<Text color={danger}>{error}</Text>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
@@ -223,11 +225,11 @@ export default function MobileEventRecapPage() {
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack space="$1">
|
||||
<Text fontSize="$xs" color="#6b7280" fontWeight="700" letterSpacing={1.2}>
|
||||
<Text fontSize="$xs" color={muted} fontWeight="700" letterSpacing={1.2}>
|
||||
{t('events.recap.badge', 'Nachbereitung')}
|
||||
</Text>
|
||||
<Text fontSize="$lg" fontWeight="800" color="#0f172a">{resolveName(event.name)}</Text>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>{resolveName(event.name)}</Text>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.recap.subtitle', 'Abschluss, Export und Galerie-Laufzeit verwalten.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
@@ -248,7 +250,7 @@ export default function MobileEventRecapPage() {
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.recap.galleryTitle', 'Galerie-Status')}
|
||||
</Text>
|
||||
<PillBadge tone="muted">{t('events.recap.galleryCounts', '{{photos}} Fotos, {{pending}} offen, {{likes}} Likes', galleryCounts)}</PillBadge>
|
||||
@@ -262,17 +264,17 @@ export default function MobileEventRecapPage() {
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Link2 size={16} color="#0f172a" />
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
<Link2 size={16} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.recap.shareLink', 'Gäste-Link')}
|
||||
</Text>
|
||||
</XStack>
|
||||
{guestLink ? (
|
||||
<Text fontSize="$sm" color="#111827" selectable>
|
||||
<Text fontSize="$sm" color={text} selectable>
|
||||
{guestLink}
|
||||
</Text>
|
||||
) : (
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.recap.noPublicUrl', 'Kein Gäste-Link gesetzt. Lege den öffentlichen Link im Event-Setup fest.')}
|
||||
</Text>
|
||||
)}
|
||||
@@ -284,7 +286,11 @@ export default function MobileEventRecapPage() {
|
||||
</XStack>
|
||||
{activeInvite?.qr_code_data_url ? (
|
||||
<XStack space="$2" alignItems="center" marginTop="$2">
|
||||
<img src={activeInvite.qr_code_data_url} alt="QR" style={{ width: 96, height: 96 }} />
|
||||
<img
|
||||
src={activeInvite.qr_code_data_url}
|
||||
alt={t('events.qr.qrAlt', 'QR code')}
|
||||
style={{ width: 96, height: 96 }}
|
||||
/>
|
||||
<CTAButton label={t('events.recap.downloadQr', 'QR herunterladen')} tone="ghost" onPress={() => downloadQr(activeInvite.qr_code_data_url)} />
|
||||
</XStack>
|
||||
) : null}
|
||||
@@ -292,12 +298,12 @@ export default function MobileEventRecapPage() {
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<ShoppingCart size={16} color="#0f172a" />
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
<ShoppingCart size={16} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.sections.addons.title', 'Add-ons & Upgrades')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.sections.addons.description', 'Zusätzliche Kontingente freischalten.')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
@@ -311,8 +317,8 @@ export default function MobileEventRecapPage() {
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Shield size={16} color="#0f172a" />
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
<Shield size={16} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.recap.settingsTitle', 'Gast-Einstellungen')}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -330,12 +336,12 @@ export default function MobileEventRecapPage() {
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Archive size={16} color="#0f172a" />
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
<Archive size={16} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.recap.archiveTitle', 'Event archivieren')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.recap.archiveCopy', 'Schließt die Galerie und markiert das Event als abgeschlossen.')}
|
||||
</Text>
|
||||
<CTAButton label={t('events.recap.archive', 'Archivieren')} onPress={() => archiveEvent()} loading={archiveBusy} />
|
||||
@@ -343,8 +349,8 @@ export default function MobileEventRecapPage() {
|
||||
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Sparkles size={16} color="#0f172a" />
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
<Sparkles size={16} color={textStrong} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.recap.feedbackTitle', 'Wie lief das Event?')}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -372,12 +378,13 @@ export default function MobileEventRecapPage() {
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
const { border, muted, textStrong } = useAdminTheme();
|
||||
return (
|
||||
<MobileCard borderColor="#e5e7eb" space="$1.5">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<MobileCard borderColor={border} space="$1.5">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fontSize="$md" fontWeight="800" color="#0f172a">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{value}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
@@ -385,9 +392,10 @@ function Stat({ label, value }: { label: string; value: string }) {
|
||||
}
|
||||
|
||||
function ToggleRow({ label, value, onToggle }: { label: string; value: boolean; onToggle: (value: boolean) => void }) {
|
||||
const { textStrong } = useAdminTheme();
|
||||
return (
|
||||
<XStack alignItems="center" justifyContent="space-between" marginTop="$1.5">
|
||||
<Text fontSize="$sm" color="#0f172a">
|
||||
<Text fontSize="$sm" color={textStrong}>
|
||||
{label}
|
||||
</Text>
|
||||
<input
|
||||
|
||||
@@ -36,15 +36,15 @@ import toast from 'react-hot-toast';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
import { Tag } from './components/Tag';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { RadioGroup } from '@tamagui/radio-group';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { buildTaskSummary } from './lib/taskSummary';
|
||||
import { buildTaskSectionCounts, type TaskSectionKey } from './lib/taskSectionCounts';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
function InlineSeparator() {
|
||||
const theme = useTheme();
|
||||
return <XStack height={1} opacity={0.7} marginLeft="$3" backgroundColor={theme.borderColor?.val ?? '#e5e7eb'} />;
|
||||
const { border } = useAdminTheme();
|
||||
return <XStack height={1} opacity={0.7} marginLeft="$3" backgroundColor={border} />;
|
||||
}
|
||||
|
||||
function TaskSummaryCard({
|
||||
@@ -102,14 +102,7 @@ export default function MobileEventTasksPage() {
|
||||
const slug = slugParam ?? activeEvent?.slug ?? null;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color12?.val ?? theme.color?.val ?? '#e5e7eb');
|
||||
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
|
||||
const subtle = String(theme.gray8?.val ?? '#94a3b8');
|
||||
const border = String(theme.borderColor?.val ?? '#334155');
|
||||
const primary = String(theme.primary?.val ?? '#007AFF');
|
||||
const danger = String(theme.red10?.val ?? '#ef4444');
|
||||
const surface = String(theme.surface?.val ?? '#ffffff');
|
||||
const { textStrong, muted, subtle, border, primary, danger, surface } = useAdminTheme();
|
||||
const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]);
|
||||
const [library, setLibrary] = React.useState<TenantTask[]>([]);
|
||||
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
|
||||
@@ -140,6 +133,7 @@ export default function MobileEventTasksPage() {
|
||||
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) });
|
||||
const [savingEmotion, setSavingEmotion] = React.useState(false);
|
||||
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
|
||||
const text = textStrong;
|
||||
const assignedRef = React.useRef<HTMLDivElement>(null);
|
||||
const libraryRef = React.useRef<HTMLDivElement>(null);
|
||||
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
|
||||
|
||||
@@ -12,10 +12,10 @@ import { getEvents, TenantEvent } from '../api';
|
||||
import { adminPath } from '../constants';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { buildEventStatusCounts, filterEventsByStatus, resolveEventStatusKey, type EventStatusKey } from './lib/eventFilters';
|
||||
import { buildEventListStats } from './lib/eventListStats';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
export default function MobileEventsPage() {
|
||||
const { t } = useTranslation('management');
|
||||
@@ -27,14 +27,7 @@ export default function MobileEventsPage() {
|
||||
const [statusFilter, setStatusFilter] = React.useState<EventStatusKey>('all');
|
||||
const searchRef = React.useRef<HTMLInputElement>(null);
|
||||
const back = useBackNavigation();
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color?.val ?? '#111827');
|
||||
const muted = String(theme.gray?.val ?? '#4b5563');
|
||||
const subtle = String(theme.gray8?.val ?? '#6b7280');
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const primary = String(theme.primary?.val ?? '#007AFF');
|
||||
const danger = String(theme.red10?.val ?? '#b91c1c');
|
||||
const surface = String(theme.surface?.val ?? '#ffffff');
|
||||
const { text, muted, subtle, border, primary, danger, surface, accentSoft, accent } = useAdminTheme();
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -130,15 +123,9 @@ function EventsList({
|
||||
onEdit: (slug: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color?.val ?? '#111827');
|
||||
const muted = String(theme.gray?.val ?? '#4b5563');
|
||||
const subtle = String(theme.gray8?.val ?? '#6b7280');
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const primary = String(theme.primary?.val ?? '#007AFF');
|
||||
const surface = String(theme.surface?.val ?? '#ffffff');
|
||||
const activeBg = String(theme.blue3?.val ?? '#e0f2fe');
|
||||
const activeBorder = String(theme.blue6?.val ?? '#bfdbfe');
|
||||
const { text, muted, subtle, border, primary, surface, accentSoft, accent } = useAdminTheme();
|
||||
const activeBg = accentSoft;
|
||||
const activeBorder = accent;
|
||||
|
||||
const statusCounts = React.useMemo(() => buildEventStatusCounts(events), [events]);
|
||||
const filteredByStatus = React.useMemo(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { resolveReturnTarget } from '../lib/returnTo';
|
||||
import { useInstallPrompt } from './hooks/useInstallPrompt';
|
||||
import { getInstallBannerDismissed, setInstallBannerDismissed, shouldShowInstallBanner } from './lib/installBanner';
|
||||
import { MobileInstallBanner } from './components/MobileInstallBanner';
|
||||
import { ADMIN_GRADIENTS } from './theme';
|
||||
|
||||
type LoginResponse = {
|
||||
token: string;
|
||||
@@ -124,13 +125,13 @@ export default function MobileLoginPage() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen items-center justify-center bg-gradient-to-br from-[#0b1020] via-[#0f172a] to-[#0b1020] px-5 py-10 text-white"
|
||||
style={safeAreaStyle}
|
||||
className="flex min-h-screen items-center justify-center px-5 py-10 text-white"
|
||||
style={{ ...safeAreaStyle, background: ADMIN_GRADIENTS.loginBackground }}
|
||||
>
|
||||
<div className="w-full max-w-md space-y-8 rounded-3xl border border-white/10 bg-white/5 p-8 shadow-2xl shadow-blue-500/10 backdrop-blur-lg">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-white/10 ring-1 ring-white/15">
|
||||
<img src="/logo-transparent-md.png" alt="Fotospiel" className="h-10 w-10" />
|
||||
<img src="/logo-transparent-md.png" alt={t('auth.logoAlt', 'Fotospiel')} className="h-10 w-10" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('login.panel_title', 'Team Login')}</h1>
|
||||
<p className="text-sm text-white/70">
|
||||
@@ -186,7 +187,8 @@ export default function MobileLoginPage() {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-2xl bg-gradient-to-r from-[#2563eb] via-[#3b82f6] to-[#22d3ee] text-sm font-semibold text-white shadow-lg shadow-blue-500/25 transition hover:brightness-110 disabled:opacity-70"
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-2xl text-sm font-semibold text-white shadow-lg shadow-rose-500/25 transition hover:brightness-110 disabled:opacity-70"
|
||||
style={{ background: ADMIN_GRADIENTS.primaryCta }}
|
||||
>
|
||||
<Loader2 className={`h-4 w-4 animate-spin ${isSubmitting ? 'opacity-100' : 'opacity-0'}`} />
|
||||
<span>{isSubmitting ? t('login.loading', 'Anmeldung …') : t('login.submit', 'Anmelden')}</span>
|
||||
|
||||
@@ -15,13 +15,13 @@ import { getApiErrorMessage } from '../lib/apiError';
|
||||
import toast from 'react-hot-toast';
|
||||
import { MobileSheet } from './components/Sheet';
|
||||
import { getEvents, TenantEvent } from '../api';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { triggerHaptic } from './lib/haptics';
|
||||
import { adminPath } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { groupNotificationsByScope, type NotificationScope, type NotificationGroup } from './lib/notificationGrouping';
|
||||
import { collectUnreadIds } from './lib/notificationUnread';
|
||||
import { formatRelativeTime } from './lib/relativeTime';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
type NotificationItem = {
|
||||
id: string;
|
||||
@@ -44,13 +44,13 @@ type NotificationSwipeRowProps = {
|
||||
|
||||
function NotificationSwipeRow({ item, onOpen, onMarkRead, children }: NotificationSwipeRowProps) {
|
||||
const { t } = useTranslation('management');
|
||||
const theme = useTheme();
|
||||
const { successBg, successText, infoBg, infoText } = useAdminTheme();
|
||||
const controls = useAnimationControls();
|
||||
const dragged = React.useRef(false);
|
||||
const markBg = String(theme.green3?.val ?? '#dcfce7');
|
||||
const markText = String(theme.green10?.val ?? '#166534');
|
||||
const detailBg = String(theme.blue3?.val ?? '#dbeafe');
|
||||
const detailText = String(theme.blue10?.val ?? '#1d4ed8');
|
||||
const markBg = successBg;
|
||||
const markText = successText;
|
||||
const detailBg = infoBg;
|
||||
const detailText = infoText;
|
||||
|
||||
const handleDrag = (_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
|
||||
dragged.current = Math.abs(info.offset.x) > 6;
|
||||
@@ -326,16 +326,10 @@ export default function MobileNotificationsPage() {
|
||||
const [events, setEvents] = React.useState<TenantEvent[]>([]);
|
||||
const [showEventPicker, setShowEventPicker] = React.useState(false);
|
||||
const back = useBackNavigation(adminPath('/mobile/dashboard'));
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color?.val ?? '#111827');
|
||||
const muted = String(theme.gray?.val ?? '#4b5563');
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const warningBg = String(theme.yellow3?.val ?? '#fef3c7');
|
||||
const warningIcon = String(theme.yellow11?.val ?? '#92400e');
|
||||
const infoBg = String(theme.blue3?.val ?? '#e0f2fe');
|
||||
const infoIcon = String(theme.primary?.val ?? '#2563eb');
|
||||
const errorText = String(theme.red10?.val ?? '#b91c1c');
|
||||
const primary = String(theme.primary?.val ?? '#007AFF');
|
||||
const { text, muted, border, warningBg, warningText, infoBg, primary, danger, accentSoft, subtle } = useAdminTheme();
|
||||
const warningIcon = warningText;
|
||||
const infoIcon = primary;
|
||||
const errorText = danger;
|
||||
|
||||
const reload = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -579,7 +573,7 @@ export default function MobileNotificationsPage() {
|
||||
borderRadius={14}
|
||||
borderWidth={1}
|
||||
borderColor={active ? primary : border}
|
||||
backgroundColor={active ? String(theme.blue3?.val ?? '#e0f2fe') : 'transparent'}
|
||||
backgroundColor={active ? accentSoft : 'transparent'}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight="700" color={active ? primary : muted}>
|
||||
{filter.label}
|
||||
@@ -598,7 +592,7 @@ export default function MobileNotificationsPage() {
|
||||
</YStack>
|
||||
) : statusFiltered.length === 0 ? (
|
||||
<MobileCard alignItems="center" justifyContent="center" space="$2">
|
||||
<Bell size={24} color={String(theme.gray9?.val ?? '#9ca3af')} />
|
||||
<Bell size={24} color={subtle} />
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
{t('mobileNotifications.emptyTitle', 'All caught up')}
|
||||
</Text>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { LogOut, User, Settings, Shield, Globe, Moon } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { YGroup } from '@tamagui/group';
|
||||
import { ListItem } from '@tamagui/list-item';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
@@ -17,18 +16,18 @@ import { adminPath } from '../constants';
|
||||
import i18n from '../i18n';
|
||||
import { useAppearance } from '@/hooks/use-appearance';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
export default function MobileProfilePage() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { appearance, updateAppearance } = useAppearance();
|
||||
const theme = useTheme();
|
||||
const textColor = String(theme.color?.val ?? '#111827');
|
||||
const mutedText = String(theme.gray?.val ?? '#4b5563');
|
||||
const borderColor = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const avatarBg = String(theme.surface?.val ?? '#e0f2fe');
|
||||
const primary = String(theme.primary?.val ?? '#2563eb');
|
||||
const { textStrong, muted, border, accentSoft, primary, subtle } = useAdminTheme();
|
||||
const textColor = textStrong;
|
||||
const mutedText = muted;
|
||||
const borderColor = border;
|
||||
const avatarBg = accentSoft;
|
||||
const back = useBackNavigation(adminPath('/mobile/dashboard'));
|
||||
|
||||
const [name, setName] = React.useState(user?.name ?? t('events.members.roles.guest', 'Guest'));
|
||||
@@ -96,7 +95,7 @@ export default function MobileProfilePage() {
|
||||
{t('mobileProfile.account', 'Account & security')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Settings size={18} color="#9ca3af" />}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
</YGroup.Item>
|
||||
@@ -112,7 +111,7 @@ export default function MobileProfilePage() {
|
||||
{t('billing.sections.packages.title', 'Packages & Billing')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Settings size={18} color="#9ca3af" />}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
</YGroup.Item>
|
||||
@@ -128,7 +127,7 @@ export default function MobileProfilePage() {
|
||||
{t('billing.sections.invoices.title', 'Invoices & Payments')}
|
||||
</Text>
|
||||
}
|
||||
iconAfter={<Settings size={18} color="#9ca3af" />}
|
||||
iconAfter={<Settings size={18} color={subtle} />}
|
||||
/>
|
||||
</Pressable>
|
||||
</YGroup.Item>
|
||||
@@ -138,7 +137,7 @@ export default function MobileProfilePage() {
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<XStack space="$2" alignItems="center">
|
||||
<Globe size={16} color="#6b7280" />
|
||||
<Globe size={16} color={muted} />
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('mobileProfile.language', 'Language')}
|
||||
</Text>
|
||||
@@ -167,7 +166,7 @@ export default function MobileProfilePage() {
|
||||
paddingHorizontal="$3"
|
||||
title={
|
||||
<XStack space="$2" alignItems="center">
|
||||
<Moon size={16} color="#6b7280" />
|
||||
<Moon size={16} color={muted} />
|
||||
<Text fontSize="$sm" color={textColor}>
|
||||
{t('mobileProfile.theme', 'Theme')}
|
||||
</Text>
|
||||
|
||||
@@ -34,14 +34,32 @@ import { LayoutElement, CANVAS_WIDTH, CANVAS_HEIGHT } from './invite-layout/sche
|
||||
import { buildInitialTextFields } from './qr/utils';
|
||||
import { adminPath } from '../constants';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { ADMIN_COLORS, ADMIN_GRADIENTS, useAdminTheme } from './theme';
|
||||
|
||||
type Step = 'background' | 'text' | 'preview';
|
||||
|
||||
const BACKGROUND_PRESETS = [
|
||||
{ id: 'bg-blue-floral', src: '/storage/layouts/backgrounds-portrait/bg-blue-floral.png', label: 'Blue Floral' },
|
||||
{ id: 'bg-goldframe', src: '/storage/layouts/backgrounds-portrait/bg-goldframe.png', label: 'Gold Frame' },
|
||||
{ id: 'gr-green-floral', src: '/storage/layouts/backgrounds-portrait/gr-green-floral.png', label: 'Green Floral' },
|
||||
{
|
||||
id: 'bg-blue-floral',
|
||||
src: '/storage/layouts/backgrounds-portrait/bg-blue-floral.png',
|
||||
labelKey: 'events.qr.backgroundPresets.blueFloral',
|
||||
label: 'Blue Floral',
|
||||
},
|
||||
{
|
||||
id: 'bg-goldframe',
|
||||
src: '/storage/layouts/backgrounds-portrait/bg-goldframe.png',
|
||||
labelKey: 'events.qr.backgroundPresets.goldFrame',
|
||||
label: 'Gold Frame',
|
||||
},
|
||||
{
|
||||
id: 'gr-green-floral',
|
||||
src: '/storage/layouts/backgrounds-portrait/gr-green-floral.png',
|
||||
labelKey: 'events.qr.backgroundPresets.greenFloral',
|
||||
label: 'Green Floral',
|
||||
},
|
||||
];
|
||||
const DEFAULT_BODY_FONT = 'Manrope';
|
||||
const DEFAULT_DISPLAY_FONT = 'Fraunces';
|
||||
|
||||
export default function MobileQrLayoutCustomizePage() {
|
||||
const { slug: slugParam, tokenId: tokenParam } = useParams<{ slug?: string; tokenId?: string }>();
|
||||
@@ -51,6 +69,7 @@ export default function MobileQrLayoutCustomizePage() {
|
||||
const layoutParam = searchParams.get('layout');
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, danger } = useAdminTheme();
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [invite, setInvite] = React.useState<EventQrInvite | null>(null);
|
||||
@@ -187,13 +206,13 @@ export default function MobileQrLayoutCustomizePage() {
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => window.location.reload()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
<RefreshCcw size={18} color="#0f172a" />
|
||||
<RefreshCcw size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
@@ -259,6 +278,7 @@ export default function MobileQrLayoutCustomizePage() {
|
||||
|
||||
function Stepper({ current, onStepChange }: { current: Step; onStepChange: (step: Step) => void }) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, border, primary, accentSoft, surface, successText } = useAdminTheme();
|
||||
const steps: { key: Step; label: string }[] = [
|
||||
{ key: 'background', label: t('events.qr.background', 'Hintergrund') },
|
||||
{ key: 'text', label: t('events.qr.text', 'Text') },
|
||||
@@ -278,16 +298,16 @@ function Stepper({ current, onStepChange }: { current: Step; onStepChange: (step
|
||||
<YStack
|
||||
borderRadius={12}
|
||||
borderWidth={1}
|
||||
borderColor={active ? '#2563EB' : completed ? '#16a34a' : '#e5e7eb'}
|
||||
backgroundColor={active ? '#eff6ff' : '#fff'}
|
||||
borderColor={active ? primary : completed ? successText : border}
|
||||
backgroundColor={active ? accentSoft : surface}
|
||||
padding="$2"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{step.label}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.step', 'Schritt')} {idx + 1}
|
||||
</Text>
|
||||
</YStack>
|
||||
@@ -295,8 +315,8 @@ function Stepper({ current, onStepChange }: { current: Step; onStepChange: (step
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
<YStack height={8} borderRadius={999} backgroundColor="#e5e7eb" overflow="hidden">
|
||||
<YStack height="100%" width={`${progress}%`} backgroundColor="#2563EB" />
|
||||
<YStack height={8} borderRadius={999} backgroundColor={border} overflow="hidden">
|
||||
<YStack height="100%" width={`${progress}%`} backgroundColor={primary} />
|
||||
</YStack>
|
||||
</YStack>
|
||||
);
|
||||
@@ -400,10 +420,10 @@ function renderEventName(name: TenantEvent['name'] | null | undefined): string |
|
||||
|
||||
function getDefaultSlots(): Record<string, SlotDefinition> {
|
||||
return {
|
||||
headline: { x: 0.08, y: 0.12, w: 0.84, fontSize: 90, fontWeight: 800, fontFamily: 'Playfair Display', align: 'center' },
|
||||
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: 'Montserrat', align: 'center' },
|
||||
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 26, fontFamily: 'Lora', lineHeight: 1.4, align: 'center' },
|
||||
instructions: { x: 0.1, y: 0.7, w: 0.8, fontSize: 24, fontFamily: 'Lora', lineHeight: 1.5 },
|
||||
headline: { x: 0.08, y: 0.12, w: 0.84, fontSize: 90, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' },
|
||||
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' },
|
||||
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 26, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' },
|
||||
instructions: { x: 0.1, y: 0.7, w: 0.8, fontSize: 24, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.5 },
|
||||
qr: { x: 0.39, y: 0.37, w: 0.27 },
|
||||
};
|
||||
}
|
||||
@@ -433,10 +453,10 @@ function resolveSlots(layout: EventQrInviteLayout | null, isFoldable: boolean, o
|
||||
|
||||
const baseSlots = isFoldable
|
||||
? {
|
||||
headline: { x: 0.08, y: 0.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: 'Playfair Display', align: 'center' },
|
||||
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: 'Montserrat', align: 'center' },
|
||||
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: 'Lora', lineHeight: 1.4, align: 'center' },
|
||||
instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: 'Lora', lineHeight: 1.3 },
|
||||
headline: { x: 0.08, y: 0.15, w: 0.84, fontSize: 50, fontWeight: 800, fontFamily: DEFAULT_DISPLAY_FONT, align: 'center' },
|
||||
subtitle: { x: 0.1, y: 0.21, w: 0.8, fontSize: 37, fontWeight: 600, fontFamily: DEFAULT_BODY_FONT, align: 'center' },
|
||||
description: { x: 0.1, y: 0.66, w: 0.8, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.4, align: 'center' },
|
||||
instructions: { x: 0.12, y: 0.36, w: 0.76, fontSize: 13, fontFamily: DEFAULT_BODY_FONT, lineHeight: 1.3 },
|
||||
qr: { x: 0.3, y: 0.3, w: 0.28 },
|
||||
}
|
||||
: getDefaultSlots();
|
||||
@@ -498,9 +518,9 @@ function buildFabricOptions({
|
||||
const slots = resolveSlots(layout, isFoldable, slotOverrides);
|
||||
|
||||
const elements: LayoutElement[] = [];
|
||||
const textColor = layout?.preview?.text ?? '#0f172a';
|
||||
const accentColor = layout?.preview?.accent ?? '#2563EB';
|
||||
const secondaryColor = layout?.preview?.secondary ?? '#1f2937';
|
||||
const textColor = layout?.preview?.text ?? ADMIN_COLORS.backdrop;
|
||||
const accentColor = layout?.preview?.accent ?? ADMIN_COLORS.primary;
|
||||
const secondaryColor = layout?.preview?.secondary ?? ADMIN_COLORS.text;
|
||||
const badgeColor = layout?.preview?.badge ?? accentColor;
|
||||
|
||||
const rotatePoint = (cx: number, cy: number, x: number, y: number, angleDeg: number) => {
|
||||
@@ -617,7 +637,7 @@ function buildFabricOptions({
|
||||
badgeColor,
|
||||
qrCodeDataUrl: qrPngUrl ?? (qrUrl ? `https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(qrUrl)}` : null),
|
||||
logoDataUrl: null,
|
||||
backgroundColor: backgroundSolid ?? layout?.preview?.background ?? '#ffffff',
|
||||
backgroundColor: backgroundSolid ?? layout?.preview?.background ?? ADMIN_COLORS.surface,
|
||||
backgroundGradient,
|
||||
backgroundImageUrl,
|
||||
readOnly: true,
|
||||
@@ -639,7 +659,7 @@ function BackgroundStep({
|
||||
saving,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
presets: { id: string; src: string; label: string }[];
|
||||
presets: { id: string; src: string; labelKey: string; label: string }[];
|
||||
selectedPreset: string | null;
|
||||
onSelectPreset: (id: string) => void;
|
||||
selectedLayout: EventQrInviteLayout | null;
|
||||
@@ -652,6 +672,7 @@ function BackgroundStep({
|
||||
saving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, border, primary, accentSoft, surface, surfaceMuted } = useAdminTheme();
|
||||
const resolvedLayout =
|
||||
selectedLayout ??
|
||||
layouts.find((layout) => {
|
||||
@@ -664,25 +685,55 @@ function BackgroundStep({
|
||||
|
||||
const isFoldable = (resolvedLayout?.panel_mode ?? '').toLowerCase() === 'double-mirror';
|
||||
const formatPaper = resolvePaper(resolvedLayout).toUpperCase();
|
||||
const orientation = ((resolvedLayout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toLowerCase() === 'landscape' ? 'Landscape' : 'Portrait';
|
||||
const isLandscape = ((resolvedLayout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toLowerCase() === 'landscape';
|
||||
const orientationLabel = isLandscape
|
||||
? t('events.qr.orientation.landscape', 'Landscape')
|
||||
: t('events.qr.orientation.portrait', 'Portrait');
|
||||
const formatLabel = isFoldable
|
||||
? `${formatPaper} Landscape (double A5/mirrored)`
|
||||
: `${formatPaper} ${orientation}`;
|
||||
? t('events.qr.formatLabel.foldable', '{{paper}} {{orientation}} (double A5/mirrored)', {
|
||||
paper: formatPaper,
|
||||
orientation: orientationLabel,
|
||||
})
|
||||
: t('events.qr.formatLabel.standard', '{{paper}} {{orientation}}', {
|
||||
paper: formatPaper,
|
||||
orientation: orientationLabel,
|
||||
});
|
||||
const disablePresets = isFoldable;
|
||||
const gradientPresets = [
|
||||
{ angle: 180, stops: ['#F8FAFC', '#EEF2FF', '#F8FAFC'], label: 'Soft Lilac' },
|
||||
{ angle: 135, stops: ['#FEE2E2', '#EFF6FF', '#ECFDF3'], label: 'Pastell' },
|
||||
{ angle: 210, stops: ['#0B132B', '#1C2541', '#274690'], label: 'Midnight' },
|
||||
{
|
||||
angle: 180,
|
||||
stops: ['#F8FAFC', '#EEF2FF', '#F8FAFC'],
|
||||
labelKey: 'events.qr.gradientPresets.softLilac',
|
||||
label: 'Soft Lilac',
|
||||
},
|
||||
{
|
||||
angle: 135,
|
||||
stops: ['#FEE2E2', '#EFF6FF', '#ECFDF3'],
|
||||
labelKey: 'events.qr.gradientPresets.pastel',
|
||||
label: 'Pastel',
|
||||
},
|
||||
{
|
||||
angle: 210,
|
||||
stops: ['#0B132B', '#1C2541', '#274690'],
|
||||
labelKey: 'events.qr.gradientPresets.midnight',
|
||||
label: 'Midnight',
|
||||
},
|
||||
];
|
||||
const solidPresets = [
|
||||
ADMIN_COLORS.surface,
|
||||
ADMIN_COLORS.surfaceMuted,
|
||||
ADMIN_COLORS.backdrop,
|
||||
ADMIN_COLORS.primary,
|
||||
ADMIN_COLORS.warning,
|
||||
];
|
||||
const solidPresets = ['#FFFFFF', '#F8FAFC', '#0F172A', '#2563EB', '#F59E0B'];
|
||||
|
||||
return (
|
||||
<YStack space="$3" marginTop="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Pressable onPress={onBack}>
|
||||
<XStack alignItems="center" space="$1">
|
||||
<ArrowLeft size={16} color="#111827" />
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
<ArrowLeft size={16} color={textStrong} />
|
||||
<Text fontSize="$sm" color={textStrong}>
|
||||
{t('common.back', 'Zurück')}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -691,10 +742,10 @@ function BackgroundStep({
|
||||
</XStack>
|
||||
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{disablePresets
|
||||
? t('events.qr.backgroundPicker', 'Hintergrund für A5 (Gradient/Farbe)')
|
||||
: t('events.qr.backgroundPicker', `Hintergrund auswählen (${formatLabel})`)}
|
||||
? t('events.qr.backgroundPickerFoldable', 'Hintergrund für A5 (Gradient/Farbe)')
|
||||
: t('events.qr.backgroundPicker', 'Hintergrund auswählen ({{formatLabel}})', { formatLabel })}
|
||||
</Text>
|
||||
{!disablePresets ? (
|
||||
<>
|
||||
@@ -709,8 +760,8 @@ function BackgroundStep({
|
||||
borderRadius={14}
|
||||
overflow="hidden"
|
||||
borderWidth={isSelected ? 2 : 1}
|
||||
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
|
||||
backgroundColor="#f8fafc"
|
||||
borderColor={isSelected ? primary : border}
|
||||
backgroundColor={surfaceMuted}
|
||||
>
|
||||
<YStack
|
||||
flex={1}
|
||||
@@ -719,8 +770,8 @@ function BackgroundStep({
|
||||
backgroundPosition="center"
|
||||
/>
|
||||
<XStack padding="$2" justifyContent="space-between" alignItems="center" backgroundColor="rgba(255,255,255,0.92)">
|
||||
<Text fontSize="$xs" color="#111827">
|
||||
{preset.label}
|
||||
<Text fontSize="$xs" color={textStrong}>
|
||||
{t(preset.labelKey, preset.label)}
|
||||
</Text>
|
||||
{isSelected ? <PillBadge tone="muted">{t('common.selected', 'Ausgewählt')}</PillBadge> : null}
|
||||
</XStack>
|
||||
@@ -729,20 +780,20 @@ function BackgroundStep({
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.backgroundNote', 'Diese Presets sind für A4 Hochformat. Spiegelung erfolgt automatisch bei Tischkarten.')}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.foldableBackgroundNote', 'Für A5 Tischkarten bitte Gradient oder Vollfarbe wählen.')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<YStack space="$2" marginTop="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{t('events.qr.gradients', 'Gradienten')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.qr.gradients', 'Gradienten')}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{gradientPresets.map((gradient, idx) => {
|
||||
const isSelected =
|
||||
@@ -753,13 +804,18 @@ function BackgroundStep({
|
||||
backgroundImage: `linear-gradient(${gradient.angle}deg, ${gradient.stops.join(',')})`,
|
||||
} as React.CSSProperties;
|
||||
return (
|
||||
<Pressable key={idx} onPress={() => onSelectGradient(gradient)} style={{ width: '30%' }}>
|
||||
<Pressable
|
||||
key={idx}
|
||||
onPress={() => onSelectGradient(gradient)}
|
||||
style={{ width: '30%' }}
|
||||
aria-label={t(gradient.labelKey, gradient.label)}
|
||||
>
|
||||
<YStack
|
||||
height={70}
|
||||
borderRadius={12}
|
||||
overflow="hidden"
|
||||
borderWidth={isSelected ? 2 : 1}
|
||||
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
|
||||
borderColor={isSelected ? primary : border}
|
||||
style={style}
|
||||
/>
|
||||
</Pressable>
|
||||
@@ -769,7 +825,7 @@ function BackgroundStep({
|
||||
</YStack>
|
||||
|
||||
<YStack space="$2" marginTop="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.qr.colors', 'Vollfarbe')}
|
||||
</Text>
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
@@ -782,7 +838,7 @@ function BackgroundStep({
|
||||
borderRadius={12}
|
||||
overflow="hidden"
|
||||
borderWidth={isSelected ? 2 : 1}
|
||||
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
|
||||
borderColor={isSelected ? primary : border}
|
||||
backgroundColor={color}
|
||||
/>
|
||||
</Pressable>
|
||||
@@ -840,8 +896,8 @@ function TextStep({
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Pressable onPress={onBack}>
|
||||
<XStack alignItems="center" space="$1">
|
||||
<ArrowLeft size={16} color="#111827" />
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
<ArrowLeft size={16} color={textStrong} />
|
||||
<Text fontSize="$sm" color={textStrong}>
|
||||
{t('common.back', 'Zurück')}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -850,7 +906,7 @@ function TextStep({
|
||||
</XStack>
|
||||
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.qr.textFields', 'Texte')}
|
||||
</Text>
|
||||
<Input
|
||||
@@ -875,9 +931,9 @@ function TextStep({
|
||||
</YStack>
|
||||
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
{t('events.qr.instructions', 'Anleitung')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.qr.instructions', 'Anleitung')}
|
||||
</Text>
|
||||
{textFields.instructions.map((item, idx) => (
|
||||
<XStack key={idx} alignItems="center" space="$2">
|
||||
<TextArea
|
||||
@@ -896,10 +952,10 @@ function TextStep({
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
backgroundColor="#fff"
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
>
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
–
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -913,10 +969,10 @@ function TextStep({
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
backgroundColor="#fff"
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
>
|
||||
<Plus size={18} color="#111827" />
|
||||
<Plus size={18} color={textStrong} />
|
||||
</XStack>
|
||||
</Pressable>
|
||||
) : null}
|
||||
@@ -953,7 +1009,7 @@ function PreviewStep({
|
||||
backgroundPreset: string | null;
|
||||
backgroundGradient: { angle: number; stops: string[] } | null;
|
||||
backgroundSolid: string | null;
|
||||
presets: { id: string; src: string; label: string }[];
|
||||
presets: { id: string; src: string; labelKey: string; label: string }[];
|
||||
textFields: { headline: string; subtitle: string; description: string; instructions: string[] };
|
||||
qrUrl: string;
|
||||
qrPngUrl?: string | null;
|
||||
@@ -963,6 +1019,7 @@ function PreviewStep({
|
||||
tenantFonts: TenantFont[];
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, border, surface } = useAdminTheme();
|
||||
const presetSrc = backgroundPreset ? presets.find((p) => p.id === backgroundPreset)?.src ?? null : null;
|
||||
const resolvedBgGradient =
|
||||
backgroundGradient ??
|
||||
@@ -1039,7 +1096,10 @@ function PreviewStep({
|
||||
|
||||
const aspectRatio = `${canvasBase.width}/${canvasBase.height}`;
|
||||
const paper = resolvePaper(layout);
|
||||
const orientation = ((layout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toLowerCase() === 'landscape' ? 'landscape' : 'portrait';
|
||||
const isLandscape = ((layout?.orientation ?? (isFoldable ? 'landscape' : 'portrait')) || '').toLowerCase() === 'landscape';
|
||||
const orientationLabel = isLandscape
|
||||
? t('events.qr.orientation.landscape', 'Landscape')
|
||||
: t('events.qr.orientation.portrait', 'Portrait');
|
||||
const qrImageSrc = qrPngUrl ?? qrUrl ?? '';
|
||||
|
||||
return (
|
||||
@@ -1047,19 +1107,19 @@ function PreviewStep({
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Pressable onPress={onBack}>
|
||||
<XStack alignItems="center" space="$1">
|
||||
<ArrowLeft size={16} color="#111827" />
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
<ArrowLeft size={16} color={textStrong} />
|
||||
<Text fontSize="$sm" color={textStrong}>
|
||||
{t('common.back', 'Zurück')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Pressable>
|
||||
<PillBadge tone="muted">
|
||||
{paper.toUpperCase()} {orientation === 'landscape' ? 'Landscape' : 'Portrait'}
|
||||
{paper.toUpperCase()} {orientationLabel}
|
||||
</PillBadge>
|
||||
</XStack>
|
||||
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.qr.preview', 'Vorschau')}
|
||||
</Text>
|
||||
<YStack
|
||||
@@ -1068,22 +1128,22 @@ function PreviewStep({
|
||||
borderRadius={16}
|
||||
overflow="hidden"
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
backgroundColor="#fff"
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
style={{ aspectRatio }}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
{previewLoading ? (
|
||||
<Text color="#6b7280">{t('common.loading', 'Lädt Vorschau …')}</Text>
|
||||
<Text color={muted}>{t('common.loading', 'Lädt Vorschau …')}</Text>
|
||||
) : previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="QR Layout Preview"
|
||||
alt={t('events.qr.previewAlt', 'QR layout preview')}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : (
|
||||
<Text color="#6b7280">{t('events.qr.missing', 'Kein QR-Link vorhanden')}</Text>
|
||||
<Text color={muted}>{t('events.qr.missing', 'Kein QR-Link vorhanden')}</Text>
|
||||
)}
|
||||
</YStack>
|
||||
</YStack>
|
||||
@@ -1140,8 +1200,9 @@ function LayoutControls({
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const [openColorSlot, setOpenColorSlot] = React.useState<string | null>(null);
|
||||
const { border, surface, surfaceMuted, text, textStrong, muted, overlay, shadow, danger } = useAdminTheme();
|
||||
const fontOptions = React.useMemo(() => {
|
||||
const preset = ['Playfair Display', 'Lora', 'Montserrat', 'Inter', 'Roboto'];
|
||||
const preset = ['Fraunces', 'Manrope', 'Inter', 'Roboto', 'Lora'];
|
||||
const tenant = tenantFonts.map((font) => font.family);
|
||||
return Array.from(new Set([...tenant, ...preset]));
|
||||
}, [tenantFonts]);
|
||||
@@ -1149,20 +1210,24 @@ function LayoutControls({
|
||||
const numberInputStyle: React.CSSProperties = {
|
||||
width: 90,
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
background: '#fff',
|
||||
fontFamily: DEFAULT_BODY_FONT,
|
||||
background: surfaceMuted,
|
||||
color: text,
|
||||
appearance: 'textfield',
|
||||
};
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
background: '#fff',
|
||||
fontFamily: DEFAULT_BODY_FONT,
|
||||
background: surfaceMuted,
|
||||
color: text,
|
||||
};
|
||||
|
||||
const StepperInput = ({
|
||||
@@ -1185,8 +1250,8 @@ function LayoutControls({
|
||||
return (
|
||||
<XStack space="$1" alignItems="center">
|
||||
<Pressable onPress={dec}>
|
||||
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor="#e5e7eb" backgroundColor="#fff">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor={border} backgroundColor={surface}>
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
–
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -1206,8 +1271,8 @@ function LayoutControls({
|
||||
style={numberInputStyle}
|
||||
/>
|
||||
<Pressable onPress={inc}>
|
||||
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor="#e5e7eb" backgroundColor="#fff">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
<XStack width={36} height={36} borderRadius={10} alignItems="center" justifyContent="center" borderWidth={1} borderColor={border} backgroundColor={surface}>
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
+
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -1250,20 +1315,20 @@ function LayoutControls({
|
||||
|
||||
return (
|
||||
<Accordion.Item value={slotKey} key={slotKey}>
|
||||
<Accordion.Trigger padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12} backgroundColor="#f8fafc">
|
||||
<Accordion.Trigger padding="$2" borderWidth={1} borderColor={border} borderRadius={12} backgroundColor={surfaceMuted}>
|
||||
<XStack justifyContent="space-between" alignItems="center" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{label}
|
||||
</Text>
|
||||
<ChevronDown size={16} color="#6b7280" />
|
||||
<ChevronDown size={16} color={muted} />
|
||||
</XStack>
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content paddingTop="$2">
|
||||
<YStack space="$2" padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12}>
|
||||
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
|
||||
<XStack space="$3">
|
||||
<YStack flex={1} space="$1">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
X (%)
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.positionX', 'X (%)')}
|
||||
</Text>
|
||||
<XStack space="$2" alignItems="center">
|
||||
<StepperInput
|
||||
@@ -1279,10 +1344,10 @@ function LayoutControls({
|
||||
paddingVertical="$2"
|
||||
borderRadius={10}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
backgroundColor="#fff"
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
>
|
||||
<Text fontSize="$xs" color="#111827">
|
||||
<Text fontSize="$xs" color={textStrong}>
|
||||
{t('common.center', 'Zentrieren')}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -1290,8 +1355,8 @@ function LayoutControls({
|
||||
</XStack>
|
||||
</YStack>
|
||||
<YStack flex={1} space="$1">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
Y (%)
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.positionY', 'Y (%)')}
|
||||
</Text>
|
||||
<StepperInput
|
||||
value={currentY * 100}
|
||||
@@ -1302,8 +1367,8 @@ function LayoutControls({
|
||||
/>
|
||||
</YStack>
|
||||
<YStack flex={1} space="$1">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
Breite (%)
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.width', 'Breite (%)')}
|
||||
</Text>
|
||||
<StepperInput
|
||||
value={currentW * 100}
|
||||
@@ -1317,14 +1382,14 @@ function LayoutControls({
|
||||
|
||||
<XStack space="$3">
|
||||
<YStack flex={1} space="$1">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
Font Size (px)
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.fontSize', 'Font Size (px)')}
|
||||
</Text>
|
||||
<StepperInput value={override.fontSize ?? slot.fontSize ?? 16} min={8} max={200} step={1} onChange={(val) => onFontSizeChange(val)} />
|
||||
</YStack>
|
||||
<YStack flex={1} space="$1">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
Font Family
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.fontFamily', 'Font Family')}
|
||||
</Text>
|
||||
<select
|
||||
value={override.fontFamily ?? slot.fontFamily ?? ''}
|
||||
@@ -1340,7 +1405,7 @@ function LayoutControls({
|
||||
</select>
|
||||
</YStack>
|
||||
<YStack flex={1} space="$1">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.fontColor', 'Schriftfarbe')}
|
||||
</Text>
|
||||
<YStack space="$2">
|
||||
@@ -1351,14 +1416,14 @@ function LayoutControls({
|
||||
height={36}
|
||||
borderRadius={10}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
backgroundColor={override.color ?? slot.color ?? '#0f172a'}
|
||||
borderColor={border}
|
||||
backgroundColor={override.color ?? slot.color ?? textStrong}
|
||||
/>
|
||||
</Pressable>
|
||||
<input
|
||||
type="text"
|
||||
value={override.color ?? slot.color ?? ''}
|
||||
placeholder="#0f172a"
|
||||
placeholder={ADMIN_COLORS.text}
|
||||
onChange={(event) => onUpdateSlot(slotKey, { color: event.target.value })}
|
||||
style={{ ...numberInputStyle, width: 110 }}
|
||||
/>
|
||||
@@ -1373,28 +1438,28 @@ function LayoutControls({
|
||||
bottom={0}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor="rgba(0,0,0,0.35)"
|
||||
backgroundColor={overlay}
|
||||
zIndex={9999}
|
||||
onPress={() => setOpenColorSlot(null)}
|
||||
>
|
||||
<YStack
|
||||
padding="$3"
|
||||
borderRadius={16}
|
||||
backgroundColor="#fff"
|
||||
backgroundColor={surface}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
borderColor={border}
|
||||
elevation="$4"
|
||||
shadowColor="rgba(0,0,0,0.08)"
|
||||
shadowColor={shadow}
|
||||
shadowOffset={{ width: 0, height: 4 }}
|
||||
shadowOpacity={0.2}
|
||||
shadowRadius={12}
|
||||
gap="$2"
|
||||
>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.qr.fontColor', 'Schriftfarbe')}
|
||||
</Text>
|
||||
<HexColorPicker
|
||||
color={override.color ?? slot.color ?? '#0f172a'}
|
||||
color={override.color ?? slot.color ?? ADMIN_COLORS.text}
|
||||
onChange={(val) => onUpdateSlot(slotKey, { color: val })}
|
||||
style={{ width: 240, height: 200 }}
|
||||
/>
|
||||
@@ -1405,10 +1470,10 @@ function LayoutControls({
|
||||
paddingVertical="$2"
|
||||
borderRadius={10}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
backgroundColor="#fff"
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
>
|
||||
<Text fontSize="$xs" color="#111827">
|
||||
<Text fontSize="$xs" color={textStrong}>
|
||||
{t('common.close', 'Schließen')}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -1424,8 +1489,8 @@ function LayoutControls({
|
||||
|
||||
<XStack space="$2">
|
||||
<YStack flex={1} space="$1">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
Align
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.align', 'Align')}
|
||||
</Text>
|
||||
<select
|
||||
value={override.align ?? slot.align ?? 'left'}
|
||||
@@ -1438,8 +1503,8 @@ function LayoutControls({
|
||||
</select>
|
||||
</YStack>
|
||||
<YStack flex={1} space="$1">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
Line Height
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.lineHeight', 'Line Height')}
|
||||
</Text>
|
||||
<StepperInput
|
||||
value={override.lineHeight ?? slot.lineHeight ?? 1.35}
|
||||
@@ -1470,7 +1535,7 @@ function LayoutControls({
|
||||
|
||||
return (
|
||||
<YStack space="$3" marginTop="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.qr.layoutControls', 'Layout & Schrift')}
|
||||
</Text>
|
||||
<Accordion type="multiple" defaultValue={accordionDefaults}>
|
||||
@@ -1481,20 +1546,20 @@ function LayoutControls({
|
||||
|
||||
{qrSlot ? (
|
||||
<Accordion.Item value="qr">
|
||||
<Accordion.Trigger padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12} backgroundColor="#f8fafc">
|
||||
<Accordion.Trigger padding="$2" borderWidth={1} borderColor={border} borderRadius={12} backgroundColor={surfaceMuted}>
|
||||
<XStack justifyContent="space-between" alignItems="center" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.qr.qr_code_label', 'QR‑Code')}
|
||||
</Text>
|
||||
<ChevronDown size={16} color="#6b7280" />
|
||||
<ChevronDown size={16} color={muted} />
|
||||
</XStack>
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content paddingTop="$2">
|
||||
<YStack space="$2" padding="$2" borderWidth={1} borderColor="#e5e7eb" borderRadius={12}>
|
||||
<YStack space="$2" padding="$2" borderWidth={1} borderColor={border} borderRadius={12}>
|
||||
<XStack space="$2">
|
||||
<YStack flex={1} space="$1">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
X (%)
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.positionX', 'X (%)')}
|
||||
</Text>
|
||||
<StepperInput
|
||||
value={(qrOverride.x ?? qrSlot.x) * 100}
|
||||
@@ -1505,8 +1570,8 @@ function LayoutControls({
|
||||
/>
|
||||
</YStack>
|
||||
<YStack flex={1} space="$1">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
Y (%)
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.positionY', 'Y (%)')}
|
||||
</Text>
|
||||
<StepperInput
|
||||
value={(qrOverride.y ?? qrSlot.y) * 100}
|
||||
@@ -1517,8 +1582,8 @@ function LayoutControls({
|
||||
/>
|
||||
</YStack>
|
||||
<YStack flex={1} space="$1">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
Größe (%)
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.size', 'Größe (%)')}
|
||||
</Text>
|
||||
<StepperInput
|
||||
value={(qrOverride.w ?? qrSlot.w) * 100}
|
||||
@@ -1530,7 +1595,7 @@ function LayoutControls({
|
||||
</YStack>
|
||||
</XStack>
|
||||
{!qrUrl ? (
|
||||
<Text fontSize="$xs" color="#b91c1c">
|
||||
<Text fontSize="$xs" color={danger}>
|
||||
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -21,12 +21,14 @@ import toast from 'react-hot-toast';
|
||||
import { ADMIN_BASE_PATH, adminPath } from '../constants';
|
||||
import { resolveLayoutForFormat } from './qr/utils';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
export default function MobileQrPrintPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const slug = slugParam ?? null;
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, text, muted, subtle, border, surfaceMuted, primary, danger, accentSoft } = useAdminTheme();
|
||||
|
||||
const [event, setEvent] = React.useState<TenantEvent | null>(null);
|
||||
const [selectedInvite, setSelectedInvite] = React.useState<EventQrInvite | null>(null);
|
||||
@@ -80,13 +82,13 @@ export default function MobileQrPrintPage() {
|
||||
onBack={back}
|
||||
headerActions={
|
||||
<HeaderActionButton onPress={() => load()} ariaLabel={t('common.refresh', 'Refresh')}>
|
||||
<RefreshCcw size={18} color="#0f172a" />
|
||||
<RefreshCcw size={18} color={textStrong} />
|
||||
</HeaderActionButton>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
<CTAButton
|
||||
@@ -99,7 +101,7 @@ export default function MobileQrPrintPage() {
|
||||
) : null}
|
||||
|
||||
<MobileCard space="$3" alignItems="center">
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.qr.heroTitle', 'Entrance QR Code')}
|
||||
</Text>
|
||||
<YStack
|
||||
@@ -107,8 +109,8 @@ export default function MobileQrPrintPage() {
|
||||
height={180}
|
||||
borderRadius={16}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
backgroundColor="#f8fafc"
|
||||
borderColor={border}
|
||||
backgroundColor={surfaceMuted}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
overflow="hidden"
|
||||
@@ -116,21 +118,21 @@ export default function MobileQrPrintPage() {
|
||||
{qrImage ? (
|
||||
<img
|
||||
src={qrImage}
|
||||
alt="QR"
|
||||
alt={t('events.qr.qrAlt', 'QR code')}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<Text color="#9ca3af" fontSize="$sm">
|
||||
<Text color={subtle} fontSize="$sm">
|
||||
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
{qrUrl ? (
|
||||
<Text fontSize="$xs" color="#334155" textAlign="center" marginTop="$2" style={{ wordBreak: 'break-word' }}>
|
||||
<Text fontSize="$xs" color={text} textAlign="center" marginTop="$2" style={{ wordBreak: 'break-word' }}>
|
||||
{qrUrl}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.description', 'Scan to access the event guest app.')}
|
||||
</Text>
|
||||
<XStack space="$2" width="100%" marginTop="$2">
|
||||
@@ -177,10 +179,10 @@ export default function MobileQrPrintPage() {
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.step1', 'Schritt 1: Format wählen')}
|
||||
</Text>
|
||||
<Text fontSize="$md" fontWeight="800" color="#111827">
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('events.qr.layouts', 'Print Layouts')}
|
||||
</Text>
|
||||
<FormatSelection
|
||||
@@ -212,7 +214,7 @@ export default function MobileQrPrintPage() {
|
||||
</MobileCard>
|
||||
|
||||
<MobileCard space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.qr.createLink', 'Neuen QR-Link erstellen')}
|
||||
</Text>
|
||||
<CTAButton
|
||||
@@ -220,7 +222,7 @@ export default function MobileQrPrintPage() {
|
||||
onPress={async () => {
|
||||
if (!slug) return;
|
||||
try {
|
||||
const invite = await createQrInvite(slug, { label: 'Mobile Link' });
|
||||
const invite = await createQrInvite(slug, { label: t('events.qr.mobileLinkLabel', 'Mobile link') });
|
||||
setQrUrl(invite.url);
|
||||
setQrImage(invite.qr_code_data_url ?? '');
|
||||
setSelectedInvite(invite);
|
||||
@@ -246,6 +248,7 @@ function FormatSelection({
|
||||
onSelect: (format: 'a4-poster' | 'a5-foldable', layoutId: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { border, primary, accentSoft, surface, textStrong, muted, subtle } = useAdminTheme();
|
||||
|
||||
const selectLayoutId = (format: 'a4-poster' | 'a5-foldable'): string | null => {
|
||||
return resolveLayoutForFormat(format, layouts);
|
||||
@@ -278,16 +281,16 @@ function FormatSelection({
|
||||
>
|
||||
<MobileCard
|
||||
padding="$3"
|
||||
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
|
||||
borderColor={isSelected ? primary : border}
|
||||
borderWidth={isSelected ? 2 : 1}
|
||||
backgroundColor={isSelected ? '#eff6ff' : '#fff'}
|
||||
backgroundColor={isSelected ? accentSoft : surface}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" space="$3">
|
||||
<YStack space="$1" flex={1}>
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{card.title}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{card.subtitle}
|
||||
</Text>
|
||||
<XStack space="$2" alignItems="center" flexWrap="wrap">
|
||||
@@ -298,7 +301,7 @@ function FormatSelection({
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
<ChevronRight size={16} color="#9ca3af" />
|
||||
<ChevronRight size={16} color={subtle} />
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
</Pressable>
|
||||
@@ -328,6 +331,7 @@ function BackgroundStep({
|
||||
saving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, muted, border, primary, surfaceMuted } = useAdminTheme();
|
||||
const resolvedLayout =
|
||||
selectedLayout ??
|
||||
layouts.find((layout) => {
|
||||
@@ -348,8 +352,8 @@ function BackgroundStep({
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Pressable onPress={onBack}>
|
||||
<XStack alignItems="center" space="$1">
|
||||
<ArrowLeft size={16} color="#111827" />
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
<ArrowLeft size={16} color={textStrong} />
|
||||
<Text fontSize="$sm" color={textStrong}>
|
||||
{t('common.back', 'Zurück')}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -360,7 +364,7 @@ function BackgroundStep({
|
||||
</XStack>
|
||||
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t(
|
||||
'events.qr.backgroundPicker',
|
||||
disablePresets ? 'Hintergrund für A5 (Gradient/Farbe)' : `Hintergrund auswählen (${formatLabel})`
|
||||
@@ -379,8 +383,8 @@ function BackgroundStep({
|
||||
borderRadius={14}
|
||||
overflow="hidden"
|
||||
borderWidth={isSelected ? 2 : 1}
|
||||
borderColor={isSelected ? '#2563EB' : '#e5e7eb'}
|
||||
backgroundColor="#f8fafc"
|
||||
borderColor={isSelected ? primary : border}
|
||||
backgroundColor={surfaceMuted}
|
||||
>
|
||||
<YStack
|
||||
flex={1}
|
||||
@@ -389,7 +393,7 @@ function BackgroundStep({
|
||||
backgroundPosition="center"
|
||||
/>
|
||||
<XStack padding="$2" justifyContent="space-between" alignItems="center" backgroundColor="rgba(255,255,255,0.92)">
|
||||
<Text fontSize="$xs" color="#111827">
|
||||
<Text fontSize="$xs" color={textStrong}>
|
||||
{preset.label}
|
||||
</Text>
|
||||
{isSelected ? <PillBadge tone="muted">{t('common.selected', 'Ausgewählt')}</PillBadge> : null}
|
||||
@@ -399,12 +403,12 @@ function BackgroundStep({
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.backgroundNote', 'Diese Presets sind für A4 Hochformat. Spiegelung erfolgt automatisch bei Tischkarten.')}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.foldableBackgroundNote', 'Für A5 Tischkarten bitte Gradient oder Vollfarbe wählen.')}
|
||||
</Text>
|
||||
)}
|
||||
@@ -433,6 +437,7 @@ function TextStep({
|
||||
saving: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong } = useAdminTheme();
|
||||
|
||||
const updateField = (key: 'headline' | 'subtitle' | 'description', value: string) => {
|
||||
onChange({ ...textFields, [key]: value });
|
||||
@@ -458,8 +463,8 @@ function TextStep({
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Pressable onPress={onBack}>
|
||||
<XStack alignItems="center" space="$1">
|
||||
<ArrowLeft size={16} color="#111827" />
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
<ArrowLeft size={16} color={textStrong} />
|
||||
<Text fontSize="$sm" color={textStrong}>
|
||||
{t('common.back', 'Zurück')}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -468,7 +473,7 @@ function TextStep({
|
||||
</XStack>
|
||||
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.qr.textFields', 'Texte')}
|
||||
</Text>
|
||||
<StyledInput
|
||||
@@ -489,7 +494,7 @@ function TextStep({
|
||||
</YStack>
|
||||
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.qr.instructions', 'Anleitung')}
|
||||
</Text>
|
||||
{textFields.instructions.map((item, idx) => (
|
||||
@@ -537,16 +542,19 @@ function PreviewStep({
|
||||
onExport: (format: 'pdf' | 'png') => void;
|
||||
}) {
|
||||
const { t } = useTranslation('management');
|
||||
const { textStrong, text, muted, border, surfaceMuted } = useAdminTheme();
|
||||
const presetSrc = backgroundPreset ? presets.find((p) => p.id === backgroundPreset)?.src ?? null : null;
|
||||
const resolvedBg = presetSrc ?? layout?.preview?.background ?? '#f8fafc';
|
||||
const resolvedBg = presetSrc ?? layout?.preview?.background ?? surfaceMuted;
|
||||
const previewText = layout?.preview?.text ?? textStrong;
|
||||
const previewBody = layout?.preview?.text ?? text;
|
||||
|
||||
return (
|
||||
<YStack space="$3" marginTop="$2">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Pressable onPress={onBack}>
|
||||
<XStack alignItems="center" space="$1">
|
||||
<ArrowLeft size={16} color="#111827" />
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
<ArrowLeft size={16} color={textStrong} />
|
||||
<Text fontSize="$sm" color={textStrong}>
|
||||
{t('common.back', 'Zurück')}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -559,15 +567,15 @@ function PreviewStep({
|
||||
</XStack>
|
||||
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" fontWeight="700" color="#111827">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong}>
|
||||
{t('events.qr.preview', 'Vorschau')}
|
||||
</Text>
|
||||
<YStack
|
||||
borderRadius={16}
|
||||
borderWidth={1}
|
||||
borderColor="#e5e7eb"
|
||||
borderColor={border}
|
||||
overflow="hidden"
|
||||
backgroundColor={presetSrc ? 'transparent' : '#f8fafc'}
|
||||
backgroundColor={presetSrc ? 'transparent' : surfaceMuted}
|
||||
style={
|
||||
presetSrc
|
||||
? {
|
||||
@@ -575,27 +583,27 @@ function PreviewStep({
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
: { background: resolvedBg ?? '#f8fafc' }
|
||||
: { background: resolvedBg ?? surfaceMuted }
|
||||
}
|
||||
padding="$3"
|
||||
gap="$3"
|
||||
>
|
||||
<Text fontSize="$lg" fontWeight="800" color={layout?.preview?.text ?? '#0f172a'}>
|
||||
<Text fontSize="$lg" fontWeight="800" color={previewText}>
|
||||
{textFields.headline || layout?.name || t('events.qr.previewHeadline', 'Event QR')}
|
||||
</Text>
|
||||
{textFields.subtitle ? (
|
||||
<Text fontSize="$sm" color={layout?.preview?.text ?? '#1f2937'}>
|
||||
<Text fontSize="$sm" color={previewBody}>
|
||||
{textFields.subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
{textFields.description ? (
|
||||
<Text fontSize="$sm" color={layout?.preview?.text ?? '#1f2937'}>
|
||||
<Text fontSize="$sm" color={previewBody}>
|
||||
{textFields.description}
|
||||
</Text>
|
||||
) : null}
|
||||
<YStack space="$1">
|
||||
{textFields.instructions.filter((i) => i.trim().length > 0).map((item, idx) => (
|
||||
<Text key={idx} fontSize="$xs" color={layout?.preview?.text ?? '#1f2937'}>
|
||||
<Text key={idx} fontSize="$xs" color={previewBody}>
|
||||
• {item}
|
||||
</Text>
|
||||
))}
|
||||
@@ -605,15 +613,15 @@ function PreviewStep({
|
||||
<>
|
||||
<img
|
||||
src={qrImage}
|
||||
alt="QR"
|
||||
alt={t('events.qr.qrAlt', 'QR code')}
|
||||
style={{ width: 140, height: 140, objectFit: 'contain' }}
|
||||
/>
|
||||
<Text fontSize="$xs" color="#334155" textAlign="center" marginTop="$2" style={{ wordBreak: 'break-word' }}>
|
||||
<Text fontSize="$xs" color={text} textAlign="center" marginTop="$2" style={{ wordBreak: 'break-word' }}>
|
||||
{qrUrl}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('events.qr.missing', 'Kein QR-Link vorhanden')}
|
||||
</Text>
|
||||
)}
|
||||
@@ -639,13 +647,14 @@ function StyledInput({
|
||||
placeholder?: string;
|
||||
flex?: number;
|
||||
}) {
|
||||
const { border } = useAdminTheme();
|
||||
return (
|
||||
<input
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #e5e7eb',
|
||||
border: `1px solid ${border}`,
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
flex: flex ?? undefined,
|
||||
@@ -666,13 +675,14 @@ function StyledTextarea({
|
||||
onChangeText: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const { border } = useAdminTheme();
|
||||
return (
|
||||
<textarea
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid #e5e7eb',
|
||||
border: `1px solid ${border}`,
|
||||
fontSize: 14,
|
||||
outline: 'none',
|
||||
minHeight: 96,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { YGroup } from '@tamagui/group';
|
||||
import { ListItem } from '@tamagui/list-item';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Switch } from '@tamagui/switch';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { MobileShell } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
|
||||
import { useAuth } from '../auth/context';
|
||||
@@ -27,6 +26,7 @@ import { MobileInstallBanner } from './components/MobileInstallBanner';
|
||||
import { setTourSeen } from './lib/mobileTour';
|
||||
import { useBackNavigation } from './hooks/useBackNavigation';
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
type PreferenceKey = keyof NotificationPreferences;
|
||||
|
||||
@@ -47,10 +47,7 @@ export default function MobileSettingsPage() {
|
||||
const { t } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuth();
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color?.val ?? '#0f172a');
|
||||
const muted = String(theme.gray?.val ?? '#6b7280');
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const { text, muted, border, danger } = useAdminTheme();
|
||||
const [preferences, setPreferences] = React.useState<NotificationPreferences>({});
|
||||
const [defaults, setDefaults] = React.useState<NotificationPreferences>({});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
@@ -199,7 +196,7 @@ export default function MobileSettingsPage() {
|
||||
<MobileShell activeTab="profile" title={t('mobileSettings.title', 'Settings')} onBack={back}>
|
||||
{error ? (
|
||||
<MobileCard>
|
||||
<Text fontWeight="700" color="#b91c1c">
|
||||
<Text fontWeight="700" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
@@ -315,7 +312,7 @@ export default function MobileSettingsPage() {
|
||||
</YGroup>
|
||||
)}
|
||||
{pushState.error ? (
|
||||
<Text fontSize="$xs" color="#b91c1c">
|
||||
<Text fontSize="$xs" color={danger}>
|
||||
{pushState.error}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -401,7 +398,7 @@ export default function MobileSettingsPage() {
|
||||
/>
|
||||
) : null}
|
||||
{storageError ? (
|
||||
<Text fontSize="$xs" color="#b91c1c">
|
||||
<Text fontSize="$xs" color={danger}>
|
||||
{storageError}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -9,17 +9,13 @@ import { MobileCard, CTAButton } from './components/Primitives';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import { formatEventDate, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
|
||||
import { adminPath } from '../constants';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
export default function MobileTasksTabPage() {
|
||||
const { events, activeEvent, hasEvents, selectEvent } = useEventContext();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color?.val ?? '#111827');
|
||||
const muted = String(theme.gray?.val ?? '#4b5563');
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const primary = String(theme.primary?.val ?? '#007AFF');
|
||||
const { text, muted, border, primary } = useAdminTheme();
|
||||
const tasksEnabled = resolveEngagementMode(activeEvent ?? null) !== 'photo_only';
|
||||
|
||||
if (activeEvent?.slug && tasksEnabled) {
|
||||
|
||||
@@ -9,17 +9,13 @@ import { MobileCard, CTAButton } from './components/Primitives';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
|
||||
import { adminPath } from '../constants';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { useAdminTheme } from './theme';
|
||||
|
||||
export default function MobileUploadsTabPage() {
|
||||
const { events, activeEvent, hasEvents, selectEvent } = useEventContext();
|
||||
const { t, i18n } = useTranslation('management');
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color?.val ?? '#111827');
|
||||
const muted = String(theme.gray?.val ?? '#4b5563');
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const primary = String(theme.primary?.val ?? '#007AFF');
|
||||
const { text, muted, border, primary } = useAdminTheme();
|
||||
|
||||
if (activeEvent?.slug) {
|
||||
return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/photos`)} replace />;
|
||||
|
||||
@@ -17,8 +17,8 @@ vi.mock('@tamagui/core', () => ({
|
||||
color: { val: '#111827' },
|
||||
gray: { val: '#4b5563' },
|
||||
borderColor: { val: '#e5e7eb' },
|
||||
blue3: { val: '#e8f1ff' },
|
||||
blue6: { val: '#bfdbfe' },
|
||||
blue3: { val: '#FFE5EC' },
|
||||
blue6: { val: '#FFB6C1' },
|
||||
red10: { val: '#b91c1c' },
|
||||
surface: { val: '#ffffff' },
|
||||
gray12: { val: '#0f172a' },
|
||||
|
||||
@@ -3,9 +3,9 @@ import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { Home, CheckSquare, Image as ImageIcon, User } from 'lucide-react';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { withAlpha } from './colors';
|
||||
import { useAdminTheme } from '../theme';
|
||||
|
||||
const ICON_SIZE = 20;
|
||||
|
||||
@@ -13,8 +13,8 @@ export type NavKey = 'home' | 'tasks' | 'uploads' | 'profile';
|
||||
|
||||
export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) {
|
||||
const { t } = useTranslation('mobile');
|
||||
const theme = useTheme();
|
||||
const surfaceColor = String(theme.surface?.val ?? 'white');
|
||||
const { surface, border, primary, accentSoft, muted, subtle, shadow } = useAdminTheme();
|
||||
const surfaceColor = surface;
|
||||
const navSurface = withAlpha(surfaceColor, 0.92);
|
||||
const [pressedKey, setPressedKey] = React.useState<NavKey | null>(null);
|
||||
const items: Array<{ key: NavKey; icon: React.ComponentType<{ size?: number; color?: string }>; label: string }> = [
|
||||
@@ -32,11 +32,11 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
|
||||
right={0}
|
||||
backgroundColor={navSurface}
|
||||
borderTopWidth={1}
|
||||
borderColor={String(theme.borderColor?.val ?? '#e5e7eb')}
|
||||
borderColor={border}
|
||||
paddingVertical="$2"
|
||||
paddingHorizontal="$4"
|
||||
zIndex={50}
|
||||
shadowColor="#0f172a"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.08}
|
||||
shadowRadius={12}
|
||||
shadowOffset={{ width: 0, height: -4 }}
|
||||
@@ -72,7 +72,7 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$2"
|
||||
borderRadius={12}
|
||||
backgroundColor={activeState ? '#e8f1ff' : 'transparent'}
|
||||
backgroundColor={activeState ? accentSoft : 'transparent'}
|
||||
gap="$1"
|
||||
style={{
|
||||
transform: isPressed ? 'scale(0.96)' : 'scale(1)',
|
||||
@@ -87,17 +87,17 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
|
||||
width={28}
|
||||
height={3}
|
||||
borderRadius={999}
|
||||
backgroundColor={String(theme.primary?.val ?? '#007AFF')}
|
||||
backgroundColor={primary}
|
||||
/>
|
||||
) : null}
|
||||
<YStack width={ICON_SIZE} height={ICON_SIZE} alignItems="center" justifyContent="center" shrink={0}>
|
||||
<IconCmp size={ICON_SIZE} color={activeState ? String(theme.primary?.val ?? '#007AFF') : '#94a3b8'} />
|
||||
<IconCmp size={ICON_SIZE} color={activeState ? primary : subtle} />
|
||||
</YStack>
|
||||
<Text
|
||||
fontSize="$xs"
|
||||
fontWeight="700"
|
||||
fontFamily="$body"
|
||||
color={activeState ? '$primary' : '#6b7280'}
|
||||
color={activeState ? primary : muted}
|
||||
textAlign="center"
|
||||
flexShrink={1}
|
||||
>
|
||||
|
||||
@@ -2,8 +2,8 @@ import React from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { withAlpha } from './colors';
|
||||
import { useAdminTheme } from '../theme';
|
||||
|
||||
type FieldProps = {
|
||||
label: string;
|
||||
@@ -13,24 +13,21 @@ type FieldProps = {
|
||||
};
|
||||
|
||||
export function MobileField({ label, hint, error, children }: FieldProps) {
|
||||
const theme = useTheme();
|
||||
const labelColor = String(theme.color?.val ?? '#111827');
|
||||
const hintColor = String(theme.gray?.val ?? '#6b7280');
|
||||
const errorColor = String(theme.red10?.val ?? '#b91c1c');
|
||||
const { text, muted, danger } = useAdminTheme();
|
||||
|
||||
return (
|
||||
<YStack space="$1.5">
|
||||
<Text fontSize="$sm" fontWeight="800" color={labelColor}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||
{label}
|
||||
</Text>
|
||||
{children}
|
||||
{hint ? (
|
||||
<Text fontSize="$xs" color={hintColor}>
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{hint}
|
||||
</Text>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Text fontSize="$xs" color={errorColor}>
|
||||
<Text fontSize="$xs" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -45,13 +42,8 @@ type ControlProps = {
|
||||
|
||||
export const MobileInput = React.forwardRef<HTMLInputElement, React.ComponentPropsWithoutRef<'input'> & ControlProps>(
|
||||
function MobileInput({ hasError = false, compact = false, style, ...props }, ref) {
|
||||
const theme = useTheme();
|
||||
const { border, surface, text, primary, danger } = useAdminTheme();
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const surface = String(theme.surface?.val ?? '#ffffff');
|
||||
const text = String(theme.color?.val ?? '#111827');
|
||||
const primary = String(theme.primary?.val ?? '#2563eb');
|
||||
const danger = String(theme.red10?.val ?? '#b91c1c');
|
||||
|
||||
const height = compact ? 36 : 44;
|
||||
const borderColor = hasError ? danger : focused ? primary : border;
|
||||
@@ -92,13 +84,8 @@ export const MobileTextArea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentPropsWithoutRef<'textarea'> & ControlProps
|
||||
>(function MobileTextArea({ hasError = false, compact = false, style, ...props }, ref) {
|
||||
const theme = useTheme();
|
||||
const { border, surface, text, primary, danger } = useAdminTheme();
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const surface = String(theme.surface?.val ?? '#ffffff');
|
||||
const text = String(theme.color?.val ?? '#111827');
|
||||
const primary = String(theme.primary?.val ?? '#2563eb');
|
||||
const danger = String(theme.red10?.val ?? '#b91c1c');
|
||||
|
||||
const borderColor = hasError ? danger : focused ? primary : border;
|
||||
const ringColor = hasError ? withAlpha(danger, 0.18) : withAlpha(primary, 0.18);
|
||||
@@ -141,14 +128,8 @@ export function MobileSelect({
|
||||
style,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<'select'> & ControlProps) {
|
||||
const theme = useTheme();
|
||||
const { border, surface, text, primary, danger, subtle } = useAdminTheme();
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const surface = String(theme.surface?.val ?? '#ffffff');
|
||||
const text = String(theme.color?.val ?? '#111827');
|
||||
const primary = String(theme.primary?.val ?? '#2563eb');
|
||||
const danger = String(theme.red10?.val ?? '#b91c1c');
|
||||
const muted = String(theme.gray?.val ?? '#94a3b8');
|
||||
|
||||
const height = compact ? 36 : 44;
|
||||
const borderColor = hasError ? danger : focused ? primary : border;
|
||||
@@ -186,7 +167,7 @@ export function MobileSelect({
|
||||
{children}
|
||||
</select>
|
||||
<XStack position="absolute" right={12} pointerEvents="none">
|
||||
<ChevronDown size={16} color={muted} />
|
||||
<ChevronDown size={16} color={subtle} />
|
||||
</XStack>
|
||||
</XStack>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { YStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { MobileSheet } from './Sheet';
|
||||
import { CTAButton } from './Primitives';
|
||||
import { useAdminTheme } from '../theme';
|
||||
|
||||
type Translator = (key: string, defaultValue?: string) => string;
|
||||
|
||||
@@ -37,20 +37,17 @@ export function LegalConsentSheet({
|
||||
copy,
|
||||
t,
|
||||
}: LegalConsentSheetProps) {
|
||||
const theme = useTheme();
|
||||
const { primary, border, surface, danger, text } = useAdminTheme();
|
||||
const [acceptedTerms, setAcceptedTerms] = React.useState(false);
|
||||
const [acceptedWaiver, setAcceptedWaiver] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const checkboxAccent = String(theme.primary?.val ?? '#2563eb');
|
||||
const checkboxBorder = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const checkboxSurface = String(theme.surface?.val ?? '#ffffff');
|
||||
const checkboxStyle = {
|
||||
marginTop: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
accentColor: checkboxAccent,
|
||||
backgroundColor: checkboxSurface,
|
||||
border: `1px solid ${checkboxBorder}`,
|
||||
accentColor: primary,
|
||||
backgroundColor: surface,
|
||||
border: `1px solid ${border}`,
|
||||
borderRadius: 4,
|
||||
appearance: 'auto',
|
||||
WebkitAppearance: 'auto',
|
||||
@@ -90,7 +87,7 @@ export function LegalConsentSheet({
|
||||
footer={
|
||||
<YStack space="$2">
|
||||
{error ? (
|
||||
<Text fontSize="$sm" color="#b91c1c">
|
||||
<Text fontSize="$sm" color={danger}>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -110,7 +107,7 @@ export function LegalConsentSheet({
|
||||
}
|
||||
>
|
||||
<YStack space="$2">
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{copy?.description ?? t('events.legalConsent.description', 'Please confirm the legal notes before buying an add-on.')}
|
||||
</Text>
|
||||
{requireTerms ? (
|
||||
@@ -121,7 +118,7 @@ export function LegalConsentSheet({
|
||||
onChange={(event) => setAcceptedTerms(event.target.checked)}
|
||||
style={checkboxStyle}
|
||||
/>
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{copy?.checkboxTerms ?? t(
|
||||
'events.legalConsent.checkboxTerms',
|
||||
'I have read and accept the Terms & Conditions, Privacy Policy, and Right of Withdrawal.',
|
||||
@@ -137,7 +134,7 @@ export function LegalConsentSheet({
|
||||
onChange={(event) => setAcceptedWaiver(event.target.checked)}
|
||||
style={checkboxStyle}
|
||||
/>
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{copy?.checkboxWaiver ?? t(
|
||||
'events.legalConsent.checkboxWaiver',
|
||||
'I expressly request immediate provision of the digital service and understand my right of withdrawal expires once fulfilled.',
|
||||
|
||||
@@ -9,8 +9,8 @@ vi.mock('@tamagui/core', () => ({
|
||||
gray11: { val: '#6b7280' },
|
||||
gray6: { val: '#e5e7eb' },
|
||||
gray2: { val: '#f8fafc' },
|
||||
blue3: { val: '#dbeafe' },
|
||||
primary: { val: '#2563eb' },
|
||||
blue3: { val: '#FFE5EC' },
|
||||
primary: { val: '#FF5A5F' },
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ import React from 'react';
|
||||
import { Download, Share2, X } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { InstallBannerState } from '../lib/installBanner';
|
||||
import { CTAButton, MobileCard } from './Primitives';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAdminTheme } from '../theme';
|
||||
|
||||
type MobileInstallBannerProps = {
|
||||
state: InstallBannerState | null;
|
||||
@@ -22,13 +22,7 @@ export function MobileInstallBanner({
|
||||
density = 'default',
|
||||
}: MobileInstallBannerProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color12?.val ?? theme.color?.val ?? '#0f172a');
|
||||
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#6b7280');
|
||||
const border = String(theme.gray6?.val ?? theme.borderColor?.val ?? '#e5e7eb');
|
||||
const accent = String(theme.primary?.val ?? '#2563eb');
|
||||
const surface = String(theme.gray2?.val ?? '#f8fafc');
|
||||
const accentSoft = String(theme.blue3?.val ?? '#dbeafe');
|
||||
const { textStrong, muted, border, primary, surfaceMuted, accentSoft } = useAdminTheme();
|
||||
|
||||
if (!state) {
|
||||
return null;
|
||||
@@ -41,7 +35,7 @@ export function MobileInstallBanner({
|
||||
<MobileCard
|
||||
space={isCompact ? '$1.5' : '$2'}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
backgroundColor={surfaceMuted}
|
||||
padding={isCompact ? '$2' : '$3'}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||
@@ -54,10 +48,10 @@ export function MobileInstallBanner({
|
||||
justifyContent="center"
|
||||
backgroundColor={accentSoft}
|
||||
>
|
||||
{isPrompt ? <Download size={16} color={accent} /> : <Share2 size={16} color={accent} />}
|
||||
{isPrompt ? <Download size={16} color={primary} /> : <Share2 size={16} color={primary} />}
|
||||
</XStack>
|
||||
<YStack flex={1} space="$0.5">
|
||||
<Text fontSize={isCompact ? '$xs' : '$sm'} fontWeight="800" color={text}>
|
||||
<Text fontSize={isCompact ? '$xs' : '$sm'} fontWeight="800" color={textStrong}>
|
||||
{t('installBanner.title', 'Install Fotospiel Admin')}
|
||||
</Text>
|
||||
<Text fontSize={isCompact ? 10 : '$xs'} color={muted}>
|
||||
@@ -70,7 +64,7 @@ export function MobileInstallBanner({
|
||||
<XStack alignItems="center" space="$2">
|
||||
{isPrompt && onInstall && isCompact ? (
|
||||
<Pressable onPress={onInstall}>
|
||||
<Text fontSize={10} fontWeight="700" color={accent}>
|
||||
<Text fontSize={10} fontWeight="700" color={primary}>
|
||||
{t('installBanner.action', 'Install')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { useEventContext } from '../../context/EventContext';
|
||||
import { BottomNav, NavKey } from './BottomNav';
|
||||
import { useMobileNav } from '../hooks/useMobileNav';
|
||||
@@ -20,6 +19,7 @@ import { withAlpha } from './colors';
|
||||
import { setTabHistory } from '../lib/tabHistory';
|
||||
import { loadPhotoQueue } from '../lib/photoModerationQueue';
|
||||
import { countQueuedPhotoActions } from '../lib/queueStatus';
|
||||
import { useAdminTheme } from '../theme';
|
||||
|
||||
type MobileShellProps = {
|
||||
title?: string;
|
||||
@@ -38,14 +38,12 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
const { t, i18n } = useTranslation('mobile');
|
||||
const { count: notificationCount } = useNotificationsBadge();
|
||||
const online = useOnlineStatus();
|
||||
const theme = useTheme();
|
||||
const backgroundColor = String(theme.background?.val ?? '#f7f8fb');
|
||||
const surfaceColor = String(theme.surface?.val ?? '#ffffff');
|
||||
const borderColor = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const textColor = String(theme.color?.val ?? '#111827');
|
||||
const mutedText = String(theme.gray?.val ?? '#6b7280');
|
||||
const warningBg = String(theme.yellow3?.val ?? '#fef3c7');
|
||||
const warningText = String(theme.yellow11?.val ?? '#92400e');
|
||||
const { background, surface, border, text, muted, warningBg, warningText, primary, danger, shadow } = useAdminTheme();
|
||||
const backgroundColor = background;
|
||||
const surfaceColor = surface;
|
||||
const borderColor = border;
|
||||
const textColor = text;
|
||||
const mutedText = muted;
|
||||
const headerSurface = withAlpha(surfaceColor, 0.94);
|
||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
|
||||
@@ -129,7 +127,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
paddingHorizontal="$4"
|
||||
paddingTop="$4"
|
||||
paddingBottom="$3"
|
||||
shadowColor="#0f172a"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.06}
|
||||
shadowRadius={10}
|
||||
shadowOffset={{ width: 0, height: 4 }}
|
||||
@@ -148,7 +146,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
{onBack ? (
|
||||
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<ChevronLeft size={28} color="#007AFF" strokeWidth={2.5} />
|
||||
<ChevronLeft size={28} color={primary} strokeWidth={2.5} />
|
||||
</XStack>
|
||||
</HeaderActionButton>
|
||||
) : (
|
||||
@@ -198,7 +196,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
height={18}
|
||||
paddingHorizontal={6}
|
||||
borderRadius={999}
|
||||
backgroundColor="#ef4444"
|
||||
backgroundColor={danger}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
@@ -218,7 +216,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
height={34}
|
||||
paddingHorizontal="$3"
|
||||
borderRadius={12}
|
||||
backgroundColor="#0ea5e9"
|
||||
backgroundColor={primary}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
space="$1.5"
|
||||
@@ -302,7 +300,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
</Text>
|
||||
<Pressable onPress={() => navigate(adminPath('/mobile/events/new'))}>
|
||||
<XStack alignItems="center" space="$2">
|
||||
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
|
||||
<Text fontSize="$sm" color={primary} fontWeight="700">
|
||||
{t('header.createEvent', 'Create event')}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -346,7 +344,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text fontSize="$xs" color="#6b7280" textAlign="center">
|
||||
<Text fontSize="$xs" color={mutedText} textAlign="center">
|
||||
{t('header.clearSelection', 'Clear selection')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { ChevronLeft } from 'lucide-react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAdminTheme } from '../theme';
|
||||
|
||||
type OnboardingShellProps = {
|
||||
eyebrow?: string;
|
||||
@@ -30,12 +30,7 @@ export function OnboardingShell({
|
||||
skipLabel,
|
||||
}: OnboardingShellProps) {
|
||||
const { t } = useTranslation('onboarding');
|
||||
const theme = useTheme();
|
||||
const background = String(theme.background?.val ?? '#f7f8fb');
|
||||
const surface = String(theme.surface?.val ?? '#ffffff');
|
||||
const text = String(theme.color?.val ?? '#111827');
|
||||
const muted = String(theme.gray?.val ?? '#6b7280');
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const { background, surface, text, textStrong, muted, border, shadow } = useAdminTheme();
|
||||
const resolvedBackLabel = backLabel ?? t('layout.back', 'Back');
|
||||
const resolvedSkipLabel = skipLabel ?? t('layout.skip', 'Skip');
|
||||
|
||||
@@ -55,7 +50,7 @@ export function OnboardingShell({
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
{onBack ? (
|
||||
<Pressable onPress={onBack} aria-label="Back">
|
||||
<Pressable onPress={onBack} aria-label={resolvedBackLabel}>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<ChevronLeft size={22} color={text} />
|
||||
<Text fontSize="$sm" fontWeight="700" color={text}>
|
||||
@@ -68,7 +63,7 @@ export function OnboardingShell({
|
||||
)}
|
||||
|
||||
{onSkip ? (
|
||||
<Pressable onPress={onSkip} aria-label="Skip">
|
||||
<Pressable onPress={onSkip} aria-label={resolvedSkipLabel}>
|
||||
<Text fontSize="$sm" fontWeight="700" color={muted}>
|
||||
{resolvedSkipLabel}
|
||||
</Text>
|
||||
@@ -84,7 +79,7 @@ export function OnboardingShell({
|
||||
borderWidth={1}
|
||||
borderColor={border}
|
||||
backgroundColor={surface}
|
||||
shadowColor="#0f172a"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.06}
|
||||
shadowRadius={14}
|
||||
shadowOffset={{ width: 0, height: 8 }}
|
||||
@@ -95,7 +90,7 @@ export function OnboardingShell({
|
||||
{eyebrow}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text fontSize="$xl" fontWeight="900" color={text}>
|
||||
<Text fontSize="$xl" fontWeight="900" color={textStrong}>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
|
||||
@@ -2,20 +2,25 @@ import React from 'react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { useAdminTheme } from '../theme';
|
||||
|
||||
export function MobileCard({ children, ...rest }: React.ComponentProps<typeof YStack>) {
|
||||
const theme = useTheme();
|
||||
export function MobileCard({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: React.ComponentProps<typeof YStack>) {
|
||||
const { surface, border, shadow } = useAdminTheme();
|
||||
return (
|
||||
<YStack
|
||||
backgroundColor={String(theme.surface?.val ?? 'white')}
|
||||
borderRadius={16}
|
||||
className={['admin-fade-up', className].filter(Boolean).join(' ')}
|
||||
backgroundColor={surface}
|
||||
borderRadius={18}
|
||||
borderWidth={1}
|
||||
borderColor={String(theme.borderColor?.val ?? '#e5e7eb')}
|
||||
shadowColor="#0f172a"
|
||||
shadowOpacity={0.06}
|
||||
shadowRadius={12}
|
||||
shadowOffset={{ width: 0, height: 8 }}
|
||||
borderColor={border}
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.08}
|
||||
shadowRadius={14}
|
||||
shadowOffset={{ width: 0, height: 10 }}
|
||||
padding="$3.5"
|
||||
space="$2"
|
||||
{...rest}
|
||||
@@ -32,7 +37,7 @@ export function PillBadge({
|
||||
tone?: 'success' | 'warning' | 'muted';
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const { theme } = useAdminTheme();
|
||||
const palette: Record<typeof tone, { bg: string; text: string; border: string }> = {
|
||||
success: {
|
||||
bg: String(theme.backgroundStrong?.val ?? '#ecfdf3'),
|
||||
@@ -83,7 +88,7 @@ export function CTAButton({
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const { primary, surface, border, text } = useAdminTheme();
|
||||
const isPrimary = tone === 'primary';
|
||||
const isDisabled = disabled || loading;
|
||||
return (
|
||||
@@ -97,15 +102,15 @@ export function CTAButton({
|
||||
}}
|
||||
>
|
||||
<XStack
|
||||
height={56}
|
||||
borderRadius={14}
|
||||
height={52}
|
||||
borderRadius={16}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
backgroundColor={isPrimary ? String(theme.primary?.val ?? '#007AFF') : String(theme.surface?.val ?? 'white')}
|
||||
backgroundColor={isPrimary ? primary : surface}
|
||||
borderWidth={isPrimary ? 0 : 1}
|
||||
borderColor={isPrimary ? 'transparent' : String(theme.borderColor?.val ?? '#e5e7eb')}
|
||||
borderColor={isPrimary ? 'transparent' : border}
|
||||
>
|
||||
<Text fontSize="$sm" fontWeight="800" color={isPrimary ? 'white' : String(theme.color?.val ?? '#111827')}>
|
||||
<Text fontSize="$sm" fontWeight="800" color={isPrimary ? 'white' : text}>
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -122,7 +127,7 @@ export function KpiTile({
|
||||
label: string;
|
||||
value: string | number;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const { accentSoft, primary, text } = useAdminTheme();
|
||||
return (
|
||||
<MobileCard borderRadius={14} padding="$3" width="32%" minWidth={110} alignItems="flex-start">
|
||||
<XStack alignItems="center" space="$2">
|
||||
@@ -130,17 +135,17 @@ export function KpiTile({
|
||||
width={32}
|
||||
height={32}
|
||||
borderRadius={12}
|
||||
backgroundColor={String(theme.blue3?.val ?? '#e5f0ff')}
|
||||
backgroundColor={accentSoft}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<IconCmp size={16} color={String(theme.primary?.val ?? '#2563eb')} />
|
||||
<IconCmp size={16} color={primary} />
|
||||
</XStack>
|
||||
<Text fontSize="$xs" color={String(theme.color?.val ?? '#111827')}>
|
||||
<Text fontSize="$xs" color={text}>
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$xl" fontWeight="800" color={String(theme.color?.val ?? '#111827')}>
|
||||
<Text fontSize="$xl" fontWeight="800" color={text}>
|
||||
{value}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
@@ -159,15 +164,16 @@ export function ActionTile({
|
||||
color,
|
||||
onPress,
|
||||
disabled = false,
|
||||
delayMs = 0,
|
||||
}: {
|
||||
icon: React.ComponentType<{ size?: number; color?: string }>;
|
||||
label: string;
|
||||
color: string;
|
||||
onPress?: () => void;
|
||||
disabled?: boolean;
|
||||
delayMs?: number;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
|
||||
const { textStrong } = useAdminTheme();
|
||||
return (
|
||||
<Pressable
|
||||
onPress={disabled ? undefined : onPress}
|
||||
@@ -175,6 +181,8 @@ export function ActionTile({
|
||||
disabled={disabled}
|
||||
>
|
||||
<YStack
|
||||
className="admin-fade-up"
|
||||
style={delayMs ? { animationDelay: `${delayMs}ms` } : undefined}
|
||||
borderRadius={16}
|
||||
padding="$3"
|
||||
space="$2.5"
|
||||
@@ -188,7 +196,7 @@ export function ActionTile({
|
||||
<XStack width={34} height={34} borderRadius={12} backgroundColor={color} alignItems="center" justifyContent="center">
|
||||
<IconCmp size={16} color="white" />
|
||||
</XStack>
|
||||
<Text fontSize="$sm" fontWeight="700" color={text} textAlign="center">
|
||||
<Text fontSize="$sm" fontWeight="700" color={textStrong} textAlign="center">
|
||||
{label}
|
||||
</Text>
|
||||
</YStack>
|
||||
@@ -205,7 +213,7 @@ export function FloatingActionButton({
|
||||
label: string;
|
||||
icon: React.ComponentType<{ size?: number; color?: string }>;
|
||||
}) {
|
||||
const theme = useTheme();
|
||||
const { primary, shadow } = useAdminTheme();
|
||||
const [pressed, setPressed] = React.useState(false);
|
||||
return (
|
||||
<Pressable
|
||||
@@ -231,8 +239,8 @@ export function FloatingActionButton({
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
space="$2"
|
||||
backgroundColor={String(theme.primary?.val ?? '#007AFF')}
|
||||
shadowColor="#0f172a"
|
||||
backgroundColor={primary}
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.2}
|
||||
shadowRadius={16}
|
||||
shadowOffset={{ width: 0, height: 8 }}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { withAlpha } from './colors';
|
||||
import { useAdminTheme } from '../theme';
|
||||
|
||||
type MobileScaffoldProps = {
|
||||
title: string;
|
||||
@@ -17,11 +17,7 @@ type MobileScaffoldProps = {
|
||||
|
||||
export function MobileScaffold({ title, onBack, rightSlot, children, footer }: MobileScaffoldProps) {
|
||||
const { t } = useTranslation('mobile');
|
||||
const theme = useTheme();
|
||||
const background = String(theme.background?.val ?? '#f7f8fb');
|
||||
const surface = String(theme.surface?.val ?? '#ffffff');
|
||||
const border = String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const textColor = String(theme.color?.val ?? '#111827');
|
||||
const { background, surface, border, text, primary } = useAdminTheme();
|
||||
const headerSurface = withAlpha(surface, 0.94);
|
||||
|
||||
return (
|
||||
@@ -48,8 +44,8 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
|
||||
{onBack ? (
|
||||
<Pressable onPress={onBack}>
|
||||
<XStack alignItems="center" space="$1.5">
|
||||
<ChevronLeft size={18} color={String(theme.primary?.val ?? '#007AFF')} />
|
||||
<Text fontSize="$sm" color={String(theme.primary?.val ?? '#007AFF')} fontWeight="600">
|
||||
<ChevronLeft size={18} color={primary} />
|
||||
<Text fontSize="$sm" color={primary} fontWeight="600">
|
||||
{t('actions.back', 'Back')}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -58,7 +54,7 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
|
||||
<Text />
|
||||
)}
|
||||
</XStack>
|
||||
<Text fontSize="$lg" fontWeight="800" color={textColor}>
|
||||
<Text fontSize="$lg" fontWeight="800" color={text}>
|
||||
{title}
|
||||
</Text>
|
||||
<XStack minWidth={40} justifyContent="flex-end">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { useAdminTheme } from '../theme';
|
||||
|
||||
type SheetProps = {
|
||||
open: boolean;
|
||||
@@ -17,11 +17,7 @@ type SheetProps = {
|
||||
|
||||
export function MobileSheet({ open, title, onClose, children, footer, bottomOffsetPx = 88 }: SheetProps) {
|
||||
const { t } = useTranslation('mobile');
|
||||
const theme = useTheme();
|
||||
const surface = String(theme.surface?.val ?? '#111827');
|
||||
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
|
||||
const muted = String(theme.gray11?.val ?? theme.gray?.val ?? '#cbd5e1');
|
||||
const overlay = String(theme.gray12?.val ?? 'rgba(0,0,0,0.6)');
|
||||
const { surface, textStrong, muted, overlay, shadow } = useAdminTheme();
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
@@ -35,7 +31,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
|
||||
padding="$4"
|
||||
paddingBottom="$7"
|
||||
space="$3"
|
||||
shadowColor="#0f172a"
|
||||
shadowColor={shadow}
|
||||
shadowOpacity={0.12}
|
||||
shadowRadius={18}
|
||||
shadowOffset={{ width: 0, height: -8 }}
|
||||
@@ -45,7 +41,7 @@ export function MobileSheet({ open, title, onClose, children, footer, bottomOffs
|
||||
style={{ marginBottom: `max(env(safe-area-inset-bottom, 0px), ${bottomOffsetPx}px)` }}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$md" fontWeight="800" color={text}>
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{title}
|
||||
</Text>
|
||||
<Pressable onPress={onClose}>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import React from 'react';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { XStack } from '@tamagui/stacks';
|
||||
import { useTheme } from '@tamagui/core';
|
||||
import { useAdminTheme } from '../theme';
|
||||
|
||||
export function Tag({ label, color }: { label: string; color?: string }) {
|
||||
const theme = useTheme();
|
||||
const baseColor = color ?? String(theme.borderColor?.val ?? '#e5e7eb');
|
||||
const textColor = String(theme.color?.val ?? '#111827');
|
||||
const { border, text } = useAdminTheme();
|
||||
const baseColor = color ?? border;
|
||||
|
||||
return (
|
||||
<XStack
|
||||
@@ -19,7 +18,7 @@ export function Tag({ label, color }: { label: string; color?: string }) {
|
||||
borderColor={`${baseColor}55`}
|
||||
alignSelf="flex-start"
|
||||
>
|
||||
<Text fontSize={11} fontWeight="600" color={textColor}>
|
||||
<Text fontSize={11} fontWeight="600" color={text}>
|
||||
{label}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { render } from '@testing-library/react';
|
||||
|
||||
vi.mock('@tamagui/core', () => ({
|
||||
useTheme: () => ({
|
||||
primary: { val: '#2563eb' },
|
||||
primary: { val: '#FF5A5F' },
|
||||
borderColor: { val: '#e5e7eb' },
|
||||
surface: { val: '#ffffff' },
|
||||
}),
|
||||
|
||||
@@ -706,7 +706,7 @@ export async function createFabricObject({
|
||||
height: element.height,
|
||||
fontSize: element.fontSize ?? 36,
|
||||
fill: element.fill ?? textColor,
|
||||
fontFamily: element.fontFamily ?? 'Lora',
|
||||
fontFamily: element.fontFamily ?? 'Manrope',
|
||||
textAlign: mapTextAlign(element.align),
|
||||
lineHeight: element.lineHeight ?? 1.5,
|
||||
charSpacing: element.letterSpacing ?? 0.5,
|
||||
@@ -719,7 +719,7 @@ export async function createFabricObject({
|
||||
height: element.height,
|
||||
fontSize: element.fontSize ?? 24,
|
||||
fill: element.fill ?? accentColor,
|
||||
fontFamily: element.fontFamily ?? 'Montserrat',
|
||||
fontFamily: element.fontFamily ?? 'Manrope',
|
||||
underline: true,
|
||||
textAlign: mapTextAlign(element.align),
|
||||
lineHeight: element.lineHeight ?? 1.5,
|
||||
@@ -799,7 +799,7 @@ export async function createFabricObject({
|
||||
height: element.height,
|
||||
fontSize: element.fontSize ?? 24,
|
||||
fill: secondaryColor,
|
||||
fontFamily: element.fontFamily ?? 'Lora',
|
||||
fontFamily: element.fontFamily ?? 'Manrope',
|
||||
textAlign: mapTextAlign(element.align),
|
||||
});
|
||||
}
|
||||
@@ -846,7 +846,7 @@ export function createTextBadge({
|
||||
top: height / 2,
|
||||
fontSize,
|
||||
fill: textColor,
|
||||
fontFamily: 'Montserrat',
|
||||
fontFamily: 'Manrope',
|
||||
originY: 'center',
|
||||
textAlign: 'center',
|
||||
lineHeight,
|
||||
|
||||
@@ -182,13 +182,13 @@ const DEFAULT_PRESET: LayoutPreset = [
|
||||
height: 220,
|
||||
fontSize: 110,
|
||||
align: 'center',
|
||||
fontFamily: 'Playfair Display',
|
||||
fontFamily: 'Fraunces',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 580, width: 800, height: 120, fontSize: 42, align: 'center', fontFamily: 'Montserrat', lineHeight: 1.4 },
|
||||
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 720, width: 900, height: 180, fontSize: 34, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
|
||||
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 580, width: 800, height: 120, fontSize: 42, align: 'center', fontFamily: 'Manrope', lineHeight: 1.4 },
|
||||
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 720, width: 900, height: 180, fontSize: 34, align: 'center', fontFamily: 'Manrope', lineHeight: 1.5 },
|
||||
{ id: 'qr', type: 'qr', x: (c) => (c.canvasWidth - 500) / 2, y: 940, width: (c) => Math.min(c.qrSize, 500), height: (c) => Math.min(c.qrSize, 500) },
|
||||
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 700) / 2, y: (c) => 940 + Math.min(c.qrSize, 500) + 160, width: 700, height: 80, align: 'center', fontSize: 26, fontFamily: 'Montserrat', lineHeight: 1.5 },
|
||||
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 700) / 2, y: (c) => 940 + Math.min(c.qrSize, 500) + 160, width: 700, height: 80, align: 'center', fontSize: 26, fontFamily: 'Manrope', lineHeight: 1.5 },
|
||||
];
|
||||
const evergreenVowsPreset: LayoutPreset = [
|
||||
// Elegant, linksbündig mit verbesserter Balance
|
||||
@@ -202,7 +202,7 @@ const evergreenVowsPreset: LayoutPreset = [
|
||||
height: 200,
|
||||
fontSize: 95,
|
||||
align: 'left',
|
||||
fontFamily: 'Playfair Display',
|
||||
fontFamily: 'Fraunces',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{
|
||||
@@ -214,7 +214,7 @@ const evergreenVowsPreset: LayoutPreset = [
|
||||
height: 140,
|
||||
fontSize: 40,
|
||||
align: 'left',
|
||||
fontFamily: 'Montserrat',
|
||||
fontFamily: 'Manrope',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
{
|
||||
@@ -226,7 +226,7 @@ const evergreenVowsPreset: LayoutPreset = [
|
||||
height: 220,
|
||||
fontSize: 32,
|
||||
align: 'left',
|
||||
fontFamily: 'Lora',
|
||||
fontFamily: 'Manrope',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
{
|
||||
@@ -237,7 +237,7 @@ const evergreenVowsPreset: LayoutPreset = [
|
||||
width: (c) => Math.min(c.qrSize, 440),
|
||||
height: (c) => Math.min(c.qrSize, 440),
|
||||
},
|
||||
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 440 - 120, y: (c) => 920 + Math.min(c.qrSize, 440) + 160, width: 440, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
|
||||
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 440 - 120, y: (c) => 920 + Math.min(c.qrSize, 440) + 160, width: 440, height: 80, align: 'center', fontSize: 24, fontFamily: 'Manrope' },
|
||||
];
|
||||
|
||||
const midnightGalaPreset: LayoutPreset = [
|
||||
@@ -251,11 +251,11 @@ const midnightGalaPreset: LayoutPreset = [
|
||||
height: 220,
|
||||
fontSize: 105,
|
||||
align: 'center',
|
||||
fontFamily: 'Playfair Display',
|
||||
fontFamily: 'Fraunces',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 900) / 2, y: 480, width: 900, height: 120, fontSize: 40, align: 'center', fontFamily: 'Montserrat', lineHeight: 1.4 },
|
||||
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 620, width: 900, height: 180, fontSize: 32, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
|
||||
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 900) / 2, y: 480, width: 900, height: 120, fontSize: 40, align: 'center', fontFamily: 'Manrope', lineHeight: 1.4 },
|
||||
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 620, width: 900, height: 180, fontSize: 32, align: 'center', fontFamily: 'Manrope', lineHeight: 1.5 },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
@@ -264,13 +264,13 @@ const midnightGalaPreset: LayoutPreset = [
|
||||
width: (c) => Math.min(c.qrSize, 480),
|
||||
height: (c) => Math.min(c.qrSize, 480),
|
||||
},
|
||||
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
|
||||
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Manrope' },
|
||||
];
|
||||
|
||||
const gardenBrunchPreset: LayoutPreset = [
|
||||
// Verspielt, asymmetrisch, aber ausbalanciert
|
||||
{ id: 'headline', type: 'headline', x: 120, y: 240, width: 900, height: 200, fontSize: 90, align: 'left', fontFamily: 'Playfair Display', lineHeight: 1.3 },
|
||||
{ id: 'subtitle', type: 'subtitle', x: 120, y: 450, width: 700, height: 120, fontSize: 40, align: 'left', fontFamily: 'Montserrat', lineHeight: 1.4 },
|
||||
{ id: 'headline', type: 'headline', x: 120, y: 240, width: 900, height: 200, fontSize: 90, align: 'left', fontFamily: 'Fraunces', lineHeight: 1.3 },
|
||||
{ id: 'subtitle', type: 'subtitle', x: 120, y: 450, width: 700, height: 120, fontSize: 40, align: 'left', fontFamily: 'Manrope', lineHeight: 1.4 },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
@@ -288,7 +288,7 @@ const gardenBrunchPreset: LayoutPreset = [
|
||||
height: 400,
|
||||
fontSize: 32,
|
||||
align: 'left',
|
||||
fontFamily: 'Lora',
|
||||
fontFamily: 'Manrope',
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
{ id: 'link', type: 'link', x: 120, y: (c) => 880 + Math.min(c.qrSize, 460) + 160, width: 460, height: 80, align: 'center', fontSize: 24 },
|
||||
@@ -305,11 +305,11 @@ const sparklerSoireePreset: LayoutPreset = [
|
||||
height: 220,
|
||||
fontSize: 100,
|
||||
align: 'center',
|
||||
fontFamily: 'Playfair Display',
|
||||
fontFamily: 'Fraunces',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 480, width: 800, height: 120, fontSize: 40, align: 'center', fontFamily: 'Montserrat' },
|
||||
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 620, width: 900, height: 180, fontSize: 32, align: 'center', fontFamily: 'Lora', lineHeight: 1.5 },
|
||||
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 480, width: 800, height: 120, fontSize: 40, align: 'center', fontFamily: 'Manrope' },
|
||||
{ id: 'description', type: 'description', x: (c) => (c.canvasWidth - 900) / 2, y: 620, width: 900, height: 180, fontSize: 32, align: 'center', fontFamily: 'Manrope', lineHeight: 1.5 },
|
||||
{
|
||||
id: 'qr',
|
||||
type: 'qr',
|
||||
@@ -318,7 +318,7 @@ const sparklerSoireePreset: LayoutPreset = [
|
||||
width: (c) => Math.min(c.qrSize, 480),
|
||||
height: (c) => Math.min(c.qrSize, 480),
|
||||
},
|
||||
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
|
||||
{ id: 'link', type: 'link', x: (c) => (c.canvasWidth - 600) / 2, y: (c) => 880 + Math.min(c.qrSize, 480) + 160, width: 600, height: 80, align: 'center', fontSize: 24, fontFamily: 'Manrope' },
|
||||
];
|
||||
|
||||
const confettiBashPreset: LayoutPreset = [
|
||||
@@ -333,7 +333,7 @@ const confettiBashPreset: LayoutPreset = [
|
||||
height: 220,
|
||||
fontSize: 110,
|
||||
align: 'center',
|
||||
fontFamily: 'Playfair Display',
|
||||
fontFamily: 'Fraunces',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{
|
||||
@@ -345,7 +345,7 @@ const confettiBashPreset: LayoutPreset = [
|
||||
height: 120,
|
||||
fontSize: 42,
|
||||
align: 'center',
|
||||
fontFamily: 'Montserrat',
|
||||
fontFamily: 'Manrope',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
{
|
||||
@@ -357,7 +357,7 @@ const confettiBashPreset: LayoutPreset = [
|
||||
height: 180,
|
||||
fontSize: 34,
|
||||
align: 'center',
|
||||
fontFamily: 'Lora',
|
||||
fontFamily: 'Manrope',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
{
|
||||
@@ -377,7 +377,7 @@ const confettiBashPreset: LayoutPreset = [
|
||||
height: 80,
|
||||
align: 'center',
|
||||
fontSize: 26,
|
||||
fontFamily: 'Montserrat',
|
||||
fontFamily: 'Manrope',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
];
|
||||
@@ -394,7 +394,7 @@ const balancedModernPreset: LayoutPreset = [
|
||||
height: 380,
|
||||
fontSize: 100,
|
||||
align: 'left',
|
||||
fontFamily: 'Playfair Display',
|
||||
fontFamily: 'Fraunces',
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
{
|
||||
@@ -406,7 +406,7 @@ const balancedModernPreset: LayoutPreset = [
|
||||
height: 140,
|
||||
fontSize: 42,
|
||||
align: 'left',
|
||||
fontFamily: 'Montserrat',
|
||||
fontFamily: 'Manrope',
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
{
|
||||
@@ -418,7 +418,7 @@ const balancedModernPreset: LayoutPreset = [
|
||||
height: 300,
|
||||
fontSize: 34,
|
||||
align: 'left',
|
||||
fontFamily: 'Lora',
|
||||
fontFamily: 'Manrope',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
{
|
||||
@@ -429,7 +429,7 @@ const balancedModernPreset: LayoutPreset = [
|
||||
width: 480,
|
||||
height: 480,
|
||||
},
|
||||
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 480 - 120, y: 1000, width: 480, height: 80, align: 'center', fontSize: 24, fontFamily: 'Montserrat' },
|
||||
{ id: 'link', type: 'link', x: (c) => c.canvasWidth - 480 - 120, y: 1000, width: 480, height: 80, align: 'center', fontSize: 24, fontFamily: 'Manrope' },
|
||||
];
|
||||
|
||||
const LAYOUT_PRESETS: Record<string, LayoutPreset> = {
|
||||
@@ -495,7 +495,7 @@ export function buildDefaultElements(
|
||||
height: resolvePresetValue(config.height, context, heightFallback),
|
||||
fontSize: config.fontSize ?? typeStyle.fontSize,
|
||||
align: config.align ?? typeStyle.align ?? 'left',
|
||||
fontFamily: config.fontFamily ?? 'Lora',
|
||||
fontFamily: config.fontFamily ?? 'Manrope',
|
||||
content: null,
|
||||
locked: config.locked ?? typeStyle.locked ?? false,
|
||||
initial: config.initial ?? true,
|
||||
|
||||
75
resources/js/admin/mobile/theme.ts
Normal file
75
resources/js/admin/mobile/theme.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useTheme } from '@tamagui/core';
|
||||
|
||||
export const ADMIN_COLORS = {
|
||||
primary: '#FF5A5F',
|
||||
primaryStrong: '#C2413B',
|
||||
accent: '#FFB6C1',
|
||||
accentSoft: '#FFE5EC',
|
||||
accentWarm: '#FFD6DE',
|
||||
warning: '#F5C542',
|
||||
success: '#06D6A0',
|
||||
danger: '#B91C1C',
|
||||
text: '#1F2937',
|
||||
textMuted: '#6B7280',
|
||||
textSubtle: '#94A3B8',
|
||||
border: '#F2E4DA',
|
||||
surface: '#FFFFFF',
|
||||
surfaceMuted: '#FFFDFB',
|
||||
backdrop: '#0F172A',
|
||||
};
|
||||
|
||||
export const ADMIN_ACTION_COLORS = {
|
||||
tasks: '#FF8A8E',
|
||||
qr: ADMIN_COLORS.warning,
|
||||
images: ADMIN_COLORS.accent,
|
||||
guests: ADMIN_COLORS.success,
|
||||
guestMessages: ADMIN_COLORS.primary,
|
||||
invites: ADMIN_COLORS.primaryStrong,
|
||||
branding: ADMIN_COLORS.accent,
|
||||
photobooth: '#FF8A8E',
|
||||
recap: ADMIN_COLORS.warning,
|
||||
packages: ADMIN_COLORS.primary,
|
||||
};
|
||||
|
||||
export const ADMIN_GRADIENTS = {
|
||||
primaryCta: `linear-gradient(135deg, ${ADMIN_COLORS.primary}, #FF8A8E, ${ADMIN_COLORS.accent})`,
|
||||
softCard: `linear-gradient(135deg, ${ADMIN_COLORS.accentSoft}, ${ADMIN_COLORS.accentWarm})`,
|
||||
loginBackground: 'linear-gradient(135deg, #0b1020, #0f172a, #0b1020)',
|
||||
};
|
||||
|
||||
export const ADMIN_MOTION = {
|
||||
tileStaggerMs: 40,
|
||||
};
|
||||
|
||||
export function useAdminTheme() {
|
||||
const theme = useTheme();
|
||||
|
||||
return {
|
||||
theme,
|
||||
background: String(theme.background?.val ?? '#FFF8F5'),
|
||||
surface: String(theme.surface?.val ?? ADMIN_COLORS.surface),
|
||||
surfaceMuted: String(theme.gray2?.val ?? ADMIN_COLORS.surfaceMuted),
|
||||
border: String(theme.borderColor?.val ?? ADMIN_COLORS.border),
|
||||
text: String(theme.color?.val ?? ADMIN_COLORS.text),
|
||||
textStrong: String(theme.color12?.val ?? theme.color?.val ?? ADMIN_COLORS.text),
|
||||
muted: String(theme.gray?.val ?? ADMIN_COLORS.textMuted),
|
||||
subtle: String(theme.gray8?.val ?? ADMIN_COLORS.textSubtle),
|
||||
primary: String(theme.primary?.val ?? ADMIN_COLORS.primary),
|
||||
accent: String(theme.blue6?.val ?? theme.accent?.val ?? ADMIN_COLORS.accent),
|
||||
accentSoft: String(theme.blue3?.val ?? ADMIN_COLORS.accentSoft),
|
||||
accentStrong: String(theme.blue10?.val ?? ADMIN_COLORS.primaryStrong),
|
||||
successBg: String(theme.green3?.val ?? '#DCFCE7'),
|
||||
successText: String(theme.green10?.val ?? '#166534'),
|
||||
dangerBg: String(theme.red3?.val ?? '#FEE2E2'),
|
||||
dangerText: String(theme.red11?.val ?? ADMIN_COLORS.danger),
|
||||
warningBg: String(theme.yellow3?.val ?? '#FFF7ED'),
|
||||
warningBorder: String(theme.yellow6?.val ?? '#FED7AA'),
|
||||
warningText: String(theme.yellow11?.val ?? '#92400E'),
|
||||
infoBg: String(theme.blue3?.val ?? ADMIN_COLORS.accentSoft),
|
||||
infoText: String(theme.blue10?.val ?? ADMIN_COLORS.primaryStrong),
|
||||
danger: String(theme.red10?.val ?? ADMIN_COLORS.danger),
|
||||
backdrop: String(theme.gray12?.val ?? ADMIN_COLORS.backdrop),
|
||||
overlay: String(theme.gray12?.val ?? 'rgba(15, 23, 42, 0.6)'),
|
||||
shadow: String(theme.shadowColor?.val ?? 'rgba(31, 41, 55, 0.12)'),
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { MobileCard, CTAButton } from '../components/Primitives';
|
||||
import { ADMIN_HOME_PATH, ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_PACKAGES_PATH, ADMIN_WELCOME_SUMMARY_PATH, adminPath } from '../../constants';
|
||||
import { getTenantPackagesOverview, trackOnboarding } from '../../api';
|
||||
import { getSelectedPackageId } from '../lib/onboardingSelection';
|
||||
import { ADMIN_COLORS } from '../theme';
|
||||
|
||||
export default function WelcomeEventPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -51,7 +52,7 @@ export default function WelcomeEventPage() {
|
||||
<Text fontSize="$sm" fontWeight="800">
|
||||
{t('eventSetup.step.title', 'Event setup in minutes')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
|
||||
{t(
|
||||
'eventSetup.step.description',
|
||||
'We guide you through name, date, mood, and tasks. Afterwards you can moderate photos and support guests live.',
|
||||
@@ -80,7 +81,7 @@ export default function WelcomeEventPage() {
|
||||
<Text fontSize="$sm" fontWeight="800">
|
||||
{t('eventSetup.cta.heading', 'Ready for your first event?')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
|
||||
{t(
|
||||
'eventSetup.cta.description',
|
||||
"You're switching to the event manager. Assign tasks, invite members, and test the gallery. You can always return to the welcome journey.",
|
||||
@@ -116,14 +117,21 @@ function FeatureRow({
|
||||
}) {
|
||||
return (
|
||||
<XStack alignItems="center" space="$2">
|
||||
<XStack width={34} height={34} borderRadius={12} backgroundColor="#e0f2fe" alignItems="center" justifyContent="center">
|
||||
<Icon size={16} color="#0284c7" />
|
||||
<XStack
|
||||
width={34}
|
||||
height={34}
|
||||
borderRadius={12}
|
||||
backgroundColor={ADMIN_COLORS.accentSoft}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon size={16} color={ADMIN_COLORS.primary} />
|
||||
</XStack>
|
||||
<YStack>
|
||||
<Text fontSize="$sm" fontWeight="700">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={ADMIN_COLORS.textMuted}>
|
||||
{body}
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
ADMIN_WELCOME_PACKAGES_PATH,
|
||||
adminPath,
|
||||
} from '../../constants';
|
||||
import { ADMIN_COLORS } from '../theme';
|
||||
|
||||
export default function WelcomeLandingPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -50,7 +51,7 @@ export default function WelcomeLandingPage() {
|
||||
<Text fontSize="$lg" fontWeight="900">
|
||||
{t('hero.title', 'Design the next Fotospiel experience')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
|
||||
{t(
|
||||
'hero.description',
|
||||
'In just a few steps you guide guests through a magical photo journey – complete with storytelling, tasks, and a moderated gallery.',
|
||||
@@ -117,8 +118,15 @@ function FeatureCard({
|
||||
<MobileCard space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<XStack width={36} height={36} borderRadius={12} backgroundColor="#e0f2fe" alignItems="center" justifyContent="center">
|
||||
<Icon size={18} color="#0284c7" />
|
||||
<XStack
|
||||
width={36}
|
||||
height={36}
|
||||
borderRadius={12}
|
||||
backgroundColor={ADMIN_COLORS.accentSoft}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon size={18} color={ADMIN_COLORS.primary} />
|
||||
</XStack>
|
||||
<Text fontSize="$sm" fontWeight="800">
|
||||
{title}
|
||||
@@ -126,7 +134,7 @@ function FeatureCard({
|
||||
</XStack>
|
||||
{badge ? <PillBadge tone="muted">{badge}</PillBadge> : null}
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
|
||||
{body}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
|
||||
@@ -11,10 +11,12 @@ import { MobileCard, CTAButton, PillBadge } from '../components/Primitives';
|
||||
import { getPackages, getTenantPackagesOverview, Package, trackOnboarding } from '../../api';
|
||||
import { ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_SUMMARY_PATH, adminPath } from '../../constants';
|
||||
import { getSelectedPackageId, setSelectedPackageId } from '../lib/onboardingSelection';
|
||||
import { useAdminTheme } from '../theme';
|
||||
|
||||
export default function WelcomePackagesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('onboarding');
|
||||
const { muted } = useAdminTheme();
|
||||
const [selectedId, setSelectedId] = React.useState<number | null>(() => getSelectedPackageId());
|
||||
|
||||
const { data: overview } = useQuery({
|
||||
@@ -60,7 +62,7 @@ export default function WelcomePackagesPage() {
|
||||
>
|
||||
{isLoading ? (
|
||||
<MobileCard>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('packages.state.loading', 'Loading packages …')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
@@ -69,7 +71,7 @@ export default function WelcomePackagesPage() {
|
||||
<Text fontSize="$sm" fontWeight="700">
|
||||
{t('packages.state.errorTitle', 'Failed to load')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('packages.state.errorDescription', 'Please try again or contact support.')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
@@ -78,7 +80,7 @@ export default function WelcomePackagesPage() {
|
||||
<Text fontSize="$sm" fontWeight="700">
|
||||
{t('packages.state.emptyTitle', 'Catalogue is empty')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('packages.state.emptyDescription', 'No packages are currently available. Reach out to support to enable new offers.')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
@@ -99,7 +101,7 @@ export default function WelcomePackagesPage() {
|
||||
<Text fontSize="$sm" fontWeight="800">
|
||||
{t('packages.step.title', 'Activate the right plan')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('packages.step.description', 'Secure capacity for your next event. Upgrade at any time – only pay for what you need.')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
@@ -132,6 +134,7 @@ function PackageCard({
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('onboarding');
|
||||
const { primary, border, accentSoft, muted } = useAdminTheme();
|
||||
const badges = [
|
||||
t('packages.card.badges.photos', { count: pkg.max_photos ?? t('summary.details.infinity', '∞') }),
|
||||
t('packages.card.badges.guests', { count: pkg.max_guests ?? t('summary.details.infinity', '∞') }),
|
||||
@@ -140,17 +143,17 @@ function PackageCard({
|
||||
|
||||
return (
|
||||
<Pressable onPress={onSelect}>
|
||||
<MobileCard borderColor={selected ? '#2563eb' : '#e5e7eb'} space="$2">
|
||||
<MobileCard borderColor={selected ? primary : border} space="$2">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<XStack width={36} height={36} borderRadius={12} backgroundColor="#e0f2fe" alignItems="center" justifyContent="center">
|
||||
<PackageIcon size={18} color="#0ea5e9" />
|
||||
<XStack width={36} height={36} borderRadius={12} backgroundColor={accentSoft} alignItems="center" justifyContent="center">
|
||||
<PackageIcon size={18} color={primary} />
|
||||
</XStack>
|
||||
<YStack>
|
||||
<Text fontSize="$sm" fontWeight="800">
|
||||
{pkg.name}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('packages.card.description', 'Ready for your next event right away.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
@@ -168,8 +171,8 @@ function PackageCard({
|
||||
</XStack>
|
||||
{selected ? (
|
||||
<XStack alignItems="center" space="$1">
|
||||
<Check size={14} color="#2563eb" />
|
||||
<Text fontSize="$xs" color="#2563eb" fontWeight="700">
|
||||
<Check size={14} color={primary} />
|
||||
<Text fontSize="$xs" color={primary} fontWeight="700">
|
||||
{t('packages.card.selected', 'Selected')}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MobileCard, CTAButton, PillBadge } from '../components/Primitives';
|
||||
import { getPackages, getTenantPackagesOverview } from '../../api';
|
||||
import { ADMIN_WELCOME_BASE_PATH, ADMIN_WELCOME_EVENT_PATH, ADMIN_WELCOME_PACKAGES_PATH, adminPath } from '../../constants';
|
||||
import { getSelectedPackageId } from '../lib/onboardingSelection';
|
||||
import { ADMIN_COLORS } from '../theme';
|
||||
|
||||
type SummaryPackage = {
|
||||
id: number;
|
||||
@@ -78,7 +79,7 @@ export default function WelcomeSummaryPage() {
|
||||
>
|
||||
{loading ? (
|
||||
<MobileCard>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
|
||||
{t('summary.state.loading', 'Checking available packages …')}
|
||||
</Text>
|
||||
</MobileCard>
|
||||
@@ -87,7 +88,7 @@ export default function WelcomeSummaryPage() {
|
||||
<Text fontSize="$sm" fontWeight="800">
|
||||
{t('summary.state.missingTitle', 'No package selected')}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
|
||||
{t('summary.state.missingDescription', 'Select a package first or refresh if data changed.')}
|
||||
</Text>
|
||||
<CTAButton label={t('summary.footer.back', 'Back to package selection')} onPress={() => navigate(ADMIN_WELCOME_PACKAGES_PATH)} />
|
||||
@@ -96,14 +97,21 @@ export default function WelcomeSummaryPage() {
|
||||
<MobileCard space="$3">
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<XStack alignItems="center" space="$2">
|
||||
<XStack width={36} height={36} borderRadius={12} backgroundColor="#e0f2fe" alignItems="center" justifyContent="center">
|
||||
<PackageIcon size={18} color="#0ea5e9" />
|
||||
<XStack
|
||||
width={36}
|
||||
height={36}
|
||||
borderRadius={12}
|
||||
backgroundColor={ADMIN_COLORS.accentSoft}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<PackageIcon size={18} color={ADMIN_COLORS.primary} />
|
||||
</XStack>
|
||||
<YStack>
|
||||
<Text fontSize="$sm" fontWeight="800">
|
||||
{resolvedPackage.name}
|
||||
</Text>
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={ADMIN_COLORS.textMuted}>
|
||||
{resolvedPackage.active
|
||||
? t('summary.details.section.statusActive', 'Already purchased')
|
||||
: t('summary.details.section.statusInactive', 'Not purchased yet')}
|
||||
@@ -139,8 +147,8 @@ export default function WelcomeSummaryPage() {
|
||||
|
||||
{resolvedPackage.active ? (
|
||||
<XStack alignItems="center" space="$2">
|
||||
<CheckCircle2 size={18} color="#22c55e" />
|
||||
<Text fontSize="$sm" color="#16a34a" fontWeight="700">
|
||||
<CheckCircle2 size={18} color={ADMIN_COLORS.success} />
|
||||
<Text fontSize="$sm" color={ADMIN_COLORS.success} fontWeight="700">
|
||||
{t('summary.details.section.statusActive', 'Already purchased')}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -162,10 +170,10 @@ export default function WelcomeSummaryPage() {
|
||||
],
|
||||
}) as string[]).map((item) => (
|
||||
<XStack key={item} space="$2">
|
||||
<Text fontSize="$xs" color="#6b7280">
|
||||
<Text fontSize="$xs" color={ADMIN_COLORS.textMuted}>
|
||||
•
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
|
||||
{item}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -194,10 +202,10 @@ export default function WelcomeSummaryPage() {
|
||||
function SummaryRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<Text fontSize="$sm" color="#111827">
|
||||
<Text fontSize="$sm" color={ADMIN_COLORS.text}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fontSize="$sm" color="#6b7280">
|
||||
<Text fontSize="$sm" color={ADMIN_COLORS.textMuted}>
|
||||
{value}
|
||||
</Text>
|
||||
</XStack>
|
||||
|
||||
Reference in New Issue
Block a user