weitere perfektionierung der neuen mobile app

This commit is contained in:
Codex Agent
2025-12-11 12:18:08 +01:00
parent 7b01a77083
commit b4417db5cd
38 changed files with 4265 additions and 3040 deletions

View File

@@ -4196,6 +4196,32 @@ var themes3 = {
surface: "#111827"
}
};
var sharedWeights = {
4: "400",
5: "500",
6: "600",
7: "700",
8: "800",
9: "900"
};
var fonts2 = {
...defaultConfig.fonts,
body: {
...defaultConfig.fonts.body,
family: "Montserrat",
weight: sharedWeights
},
heading: {
...defaultConfig.fonts.heading,
family: "Montserrat",
weight: sharedWeights
},
display: {
...defaultConfig.fonts.heading,
family: "Playfair Display",
weight: sharedWeights
}
};
var config = (0, import_core2.createTamagui)({
...defaultConfig,
animations: createAnimations({
@@ -4205,6 +4231,8 @@ var config = (0, import_core2.createTamagui)({
}),
tokens: tokens3,
themes: themes3,
fonts: fonts2,
defaultFont: "body",
shorthands: shorthands2,
media: {
...defaultConfig.media,

File diff suppressed because it is too large Load Diff

View File

@@ -125,7 +125,7 @@ class TaskController extends Controller
$task->update($payload);
$task->load(['taskCollection', 'assignedEvents']);
$task->load(['taskCollection', 'assignedEvents', 'eventType', 'emotion']);
return response()->json([
'message' => 'Task erfolgreich aktualisiert.',
@@ -357,6 +357,10 @@ class TaskController extends Controller
$data['priority'] = $original?->priority ?? 'medium';
}
if (array_key_exists('emotion_id', $data) && empty($data['emotion_id'])) {
$data['emotion_id'] = null;
}
return $data;
}

View File

@@ -51,6 +51,21 @@ class TaskStoreRequest extends FormRequest
$fail('Der Benutzer gehört nicht zu diesem Tenant.');
}
}],
'emotion_id' => ['nullable', 'exists:emotions,id', function ($attribute, $value, $fail) use ($tenantId) {
$accessible = \App\Models\Emotion::where('id', $value)
->where(function ($query) use ($tenantId) {
$query->whereNull('tenant_id');
if ($tenantId) {
$query->orWhere('tenant_id', $tenantId);
}
})
->exists();
if (! $accessible) {
$fail('Die Emotion gehört nicht zu diesem Tenant.');
}
}],
];
}

View File

@@ -51,6 +51,21 @@ class TaskUpdateRequest extends FormRequest
$fail('Der Benutzer gehört nicht zu diesem Tenant.');
}
}],
'emotion_id' => ['sometimes', 'nullable', 'exists:emotions,id', function ($attribute, $value, $fail) use ($tenantId) {
$accessible = \App\Models\Emotion::where('id', $value)
->where(function ($query) use ($tenantId) {
$query->whereNull('tenant_id');
if ($tenantId) {
$query->orWhere('tenant_id', $tenantId);
}
})
->exists();
if (! $accessible) {
$fail('Die Emotion gehört nicht zu diesem Tenant.');
}
}],
];
}

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env node
/* eslint-disable no-console */
const http = require('http');
const { execFile } = require('child_process');
const fs = require('fs');

256
package-lock.json generated
View File

@@ -30,8 +30,10 @@
"@tailwindcss/vite": "^4.1.11",
"@tamagui/button": "^1.139.2",
"@tamagui/config": "^1.139.2",
"@tamagui/font": "^1.139.3",
"@tamagui/group": "^1.139.2",
"@tamagui/list-item": "^1.139.2",
"@tamagui/radio-group": "1.139.2",
"@tamagui/stacks": "^1.139.2",
"@tamagui/text": "^1.139.2",
"@tamagui/themes": "^1.139.2",
@@ -5408,6 +5410,23 @@
"resolved": "https://registry.npmjs.org/@tamagui/cli-color/-/cli-color-1.139.2.tgz",
"integrity": "sha512-pTetpIg/TlM8/YsWWh2iXa20h9p5dkwD6rJ66N7AEVvEdmo1RUAhusE7hAWZl5lCU45KqC14w/NeIdVE/53Avg=="
},
"node_modules/@tamagui/collection": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/collection/-/collection-1.139.2.tgz",
"integrity": "sha512-qNbbRpP7wtZI/Ez+ZczO2BoIZUK0nsoqw9LQcSDjVm1qvW0Ig1rGXin4mfgvtutZp2jGaD1KTrWQLX09g4F7YA==",
"dependencies": {
"@tamagui/compose-refs": "1.139.2",
"@tamagui/constants": "1.139.2",
"@tamagui/core": "1.139.2",
"@tamagui/create-context": "1.139.2",
"@tamagui/polyfill-dev": "1.139.2",
"@tamagui/stacks": "1.139.2",
"@tamagui/use-controllable-state": "1.139.2"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/@tamagui/colors": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/colors/-/colors-1.139.2.tgz",
@@ -5506,6 +5525,31 @@
"resolved": "https://registry.npmjs.org/@tamagui/fake-react-native/-/fake-react-native-1.139.2.tgz",
"integrity": "sha512-wKk6UG7oC+cBxPkkE/VXZikiLWdFIkKANN7Z1CDqeDsYVGEtnQTpLIz5qlHHK0/dHSJQO6FG7CWlA+WOb+hacw=="
},
"node_modules/@tamagui/focusable": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/focusable/-/focusable-1.139.2.tgz",
"integrity": "sha512-ekcRXJ1YMMoD6SVhx4pAdKhvwulFJkyujAq8DjD4ZKaNNVUgPFjsHg60m2Ys4judJ4JGWhGeLTHwd9veJFi9vg==",
"license": "MIT",
"dependencies": {
"@tamagui/compose-refs": "1.139.2",
"@tamagui/web": "1.139.2"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/@tamagui/font": {
"version": "1.139.3",
"resolved": "https://registry.npmjs.org/@tamagui/font/-/font-1.139.3.tgz",
"integrity": "sha512-dcSm+9vN7mShvk/gtpANrAG3zZ3oYNsrikDCBE+cDYNUuS+ESlM2GYNWaU9YOQX6fp098D4ctJmXKjA0kcEZkQ==",
"dependencies": {
"@tamagui/constants": "1.139.3",
"@tamagui/web": "1.139.3"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/@tamagui/font-inter": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/font-inter/-/font-inter-1.139.2.tgz",
@@ -5533,6 +5577,116 @@
"react": "*"
}
},
"node_modules/@tamagui/font/node_modules/@tamagui/compose-refs": {
"version": "1.139.3",
"resolved": "https://registry.npmjs.org/@tamagui/compose-refs/-/compose-refs-1.139.3.tgz",
"integrity": "sha512-tbm1lz/Tiq8rhptWRZNPTvBDayHMa/PKWIXyIk1M/vhgoxN48VrLf92W+It5heud5WJrM+3/JCUrJNTcAQFZKQ==",
"peerDependencies": {
"react": "*"
}
},
"node_modules/@tamagui/font/node_modules/@tamagui/constants": {
"version": "1.139.3",
"resolved": "https://registry.npmjs.org/@tamagui/constants/-/constants-1.139.3.tgz",
"integrity": "sha512-4nVlKZ9TvHlWRNbjWg7M+F0CAEwZ1DnrkK7jiQOW6HQ7XxEdWRnlBuGVIrECifSSlLFImz7thPeE3jxQ/CQxrA==",
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/@tamagui/font/node_modules/@tamagui/helpers": {
"version": "1.139.3",
"resolved": "https://registry.npmjs.org/@tamagui/helpers/-/helpers-1.139.3.tgz",
"integrity": "sha512-SM7rU8PAA0JiMsXUXvXFtP90XzqShxQ0SRaiYkNmSYkHXlF3m67ppWhPMWty3VH6/gfofMlhM0ZNHwMLLGxVDg==",
"dependencies": {
"@tamagui/constants": "1.139.3",
"@tamagui/simple-hash": "1.139.3"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/@tamagui/font/node_modules/@tamagui/is-equal-shallow": {
"version": "1.139.3",
"resolved": "https://registry.npmjs.org/@tamagui/is-equal-shallow/-/is-equal-shallow-1.139.3.tgz",
"integrity": "sha512-z9Fps3es8Br6p1t10IUtA84kP1/bMbDtbCIVyF6nkxwBfP4mRuOg0CuYPAdrnlzvodfiTH3sWNCfJ8nk8VUR2Q==",
"peerDependencies": {
"react": "*"
}
},
"node_modules/@tamagui/font/node_modules/@tamagui/normalize-css-color": {
"version": "1.139.3",
"resolved": "https://registry.npmjs.org/@tamagui/normalize-css-color/-/normalize-css-color-1.139.3.tgz",
"integrity": "sha512-HomTK+G0kraa8shKsZzX2799opCpeL2WK3z49e3DcJbiVUKhWbXBPk0YY0OQ4IUvew59sm1BC7hi7o5xywV+XA==",
"dependencies": {
"@react-native/normalize-color": "^2.1.0"
}
},
"node_modules/@tamagui/font/node_modules/@tamagui/simple-hash": {
"version": "1.139.3",
"resolved": "https://registry.npmjs.org/@tamagui/simple-hash/-/simple-hash-1.139.3.tgz",
"integrity": "sha512-TzHcloamiN2FyXpKUrjXGz6QHMwOybxZIunr6iSmly9doqXXEXtPFRFKekgVxi7E7HOA4hXcY7XDNlwmK4kOhg=="
},
"node_modules/@tamagui/font/node_modules/@tamagui/timer": {
"version": "1.139.3",
"resolved": "https://registry.npmjs.org/@tamagui/timer/-/timer-1.139.3.tgz",
"integrity": "sha512-jVQQV3OPdtQg1UY/OZlEROk4Fwrybgh270lPQ7fLCx4xpADsreQ7LSjuP+qpwe9ErWXN3M4n/xxm31seyUyaOA=="
},
"node_modules/@tamagui/font/node_modules/@tamagui/types": {
"version": "1.139.3",
"resolved": "https://registry.npmjs.org/@tamagui/types/-/types-1.139.3.tgz",
"integrity": "sha512-CtmzvWoX3WyHNuc0K7yRjFoaqdhVI5WgiIeYAZ7duKAz8d8jt400Zm1LE5ljZzzGPGRb8X629q/Q6n+rY/T0/Q=="
},
"node_modules/@tamagui/font/node_modules/@tamagui/use-did-finish-ssr": {
"version": "1.139.3",
"resolved": "https://registry.npmjs.org/@tamagui/use-did-finish-ssr/-/use-did-finish-ssr-1.139.3.tgz",
"integrity": "sha512-0YXOm9FgU8PfdZk4TPxWNT+eb+QZi2okFzLH0mEro0TwsAenFxA0F3qmI3KxVSd5Cv+eHrWZWSvKkzLCQf+0DA==",
"peerDependencies": {
"react": "*"
}
},
"node_modules/@tamagui/font/node_modules/@tamagui/use-event": {
"version": "1.139.3",
"resolved": "https://registry.npmjs.org/@tamagui/use-event/-/use-event-1.139.3.tgz",
"integrity": "sha512-KobtHg0B0DgZX2cYXDpjHcGm56LTQNJupy4+A6M/ciZzUbVpSEmFfOGFoz9SSX3xySYF6p8X+EnBhzSWzQaQGw==",
"dependencies": {
"@tamagui/constants": "1.139.3"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/@tamagui/font/node_modules/@tamagui/use-force-update": {
"version": "1.139.3",
"resolved": "https://registry.npmjs.org/@tamagui/use-force-update/-/use-force-update-1.139.3.tgz",
"integrity": "sha512-IbrBTGiSEf57DfxsBj0o2K+NRDXObzCyuYVDUveDPdVh4ElBwL+uDBwKVrufrpiOa+jQIfvEqrNS1Thhw0hh6A==",
"peerDependencies": {
"react": "*"
}
},
"node_modules/@tamagui/font/node_modules/@tamagui/web": {
"version": "1.139.3",
"resolved": "https://registry.npmjs.org/@tamagui/web/-/web-1.139.3.tgz",
"integrity": "sha512-533wfM8JdG726KqxuEvaMQRPYm5hBUQSrYVFecCMHy56N1rFYtzIZNL1aaluroGb1ZgUgq9X6qlWC/GIBBLuUQ==",
"license": "MIT",
"dependencies": {
"@tamagui/compose-refs": "1.139.3",
"@tamagui/constants": "1.139.3",
"@tamagui/helpers": "1.139.3",
"@tamagui/is-equal-shallow": "1.139.3",
"@tamagui/normalize-css-color": "1.139.3",
"@tamagui/timer": "1.139.3",
"@tamagui/types": "1.139.3",
"@tamagui/use-did-finish-ssr": "1.139.3",
"@tamagui/use-event": "1.139.3",
"@tamagui/use-force-update": "1.139.3"
},
"peerDependencies": {
"react": "*",
"react-dom": "*",
"react-native": "*"
}
},
"node_modules/@tamagui/generate-themes": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/generate-themes/-/generate-themes-1.139.2.tgz",
@@ -5637,6 +5791,25 @@
"react": "*"
}
},
"node_modules/@tamagui/label": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/label/-/label-1.139.2.tgz",
"integrity": "sha512-JSXKnoB2BCFODXkRKnZbih7gywO6Ms7CYFIC4zTctFlhtAi6Oos/xIoUaQabLpyeJ5qRf+Ij9dHM+ENpe3wfpg==",
"dependencies": {
"@tamagui/compose-refs": "1.139.2",
"@tamagui/constants": "1.139.2",
"@tamagui/create-context": "1.139.2",
"@tamagui/focusable": "1.139.2",
"@tamagui/get-button-sized": "1.139.2",
"@tamagui/get-font-sized": "1.139.2",
"@tamagui/text": "1.139.2",
"@tamagui/web": "1.139.2"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/@tamagui/list-item": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/list-item/-/list-item-1.139.2.tgz",
@@ -5663,11 +5836,59 @@
"@react-native/normalize-color": "^2.1.0"
}
},
"node_modules/@tamagui/polyfill-dev": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/polyfill-dev/-/polyfill-dev-1.139.2.tgz",
"integrity": "sha512-rtvtJuo8h/2PHcBY809/4d6Rale2qkFd9PztafujGSGoxfUPPMvmF53Uy3QdGiTdtnC1cFREF4BzB71g+n1YNg=="
},
"node_modules/@tamagui/proxy-worm": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/proxy-worm/-/proxy-worm-1.139.2.tgz",
"integrity": "sha512-LQO3vDprHCxCZdTBzV7SUeTGDLB7jG171MqvUa9E04UGdPs5sNpMu/cZz4bsRg6DPhL5K5R6EmdGldCy+LhrTQ=="
},
"node_modules/@tamagui/radio-group": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/radio-group/-/radio-group-1.139.2.tgz",
"integrity": "sha512-UMRAYJ0VYSBiSKhZauOEebg+mQeArckVWRuZAp5t0x6nzigJS4xt+C85CLLSTuWCsVGICFukfDpGyzIM/lrunA==",
"dependencies": {
"@tamagui/compose-refs": "1.139.2",
"@tamagui/constants": "1.139.2",
"@tamagui/core": "1.139.2",
"@tamagui/create-context": "1.139.2",
"@tamagui/focusable": "1.139.2",
"@tamagui/get-token": "1.139.2",
"@tamagui/helpers": "1.139.2",
"@tamagui/label": "1.139.2",
"@tamagui/radio-headless": "1.139.2",
"@tamagui/roving-focus": "1.139.2",
"@tamagui/stacks": "1.139.2",
"@tamagui/use-controllable-state": "1.139.2",
"@tamagui/use-previous": "1.139.2"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/@tamagui/radio-headless": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/radio-headless/-/radio-headless-1.139.2.tgz",
"integrity": "sha512-LgURXhO1/hZbRpbKdGROkfM+/89HsBPZdp3YZJ2MVdzwT/p8MBOn2SQxM2uFUgIyAbUn4VESfJUHPSE30kMZmA==",
"dependencies": {
"@tamagui/compose-refs": "1.139.2",
"@tamagui/constants": "1.139.2",
"@tamagui/create-context": "1.139.2",
"@tamagui/focusable": "1.139.2",
"@tamagui/helpers": "1.139.2",
"@tamagui/label": "1.139.2",
"@tamagui/use-controllable-state": "1.139.2",
"@tamagui/use-previous": "1.139.2",
"@tamagui/web": "1.139.2"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/@tamagui/react-native-media-driver": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/react-native-media-driver/-/react-native-media-driver-1.139.2.tgz",
@@ -5746,6 +5967,25 @@
"react-dom": "*"
}
},
"node_modules/@tamagui/roving-focus": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/roving-focus/-/roving-focus-1.139.2.tgz",
"integrity": "sha512-OK8XfFH089HCiVFNF7zQoZDP49/7IzxueJ0iXZyyDr+lVA9Y2uUmJmccbp3J+zRcPRnrlucuGRT71vGR14kjFQ==",
"dependencies": {
"@tamagui/collection": "1.139.2",
"@tamagui/compose-refs": "1.139.2",
"@tamagui/constants": "1.139.2",
"@tamagui/core": "1.139.2",
"@tamagui/create-context": "1.139.2",
"@tamagui/helpers": "1.139.2",
"@tamagui/use-controllable-state": "1.139.2",
"@tamagui/use-direction": "1.139.2",
"@tamagui/use-event": "1.139.2"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/@tamagui/shorthands": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/shorthands/-/shorthands-1.139.2.tgz",
@@ -5902,6 +6142,14 @@
"react": "*"
}
},
"node_modules/@tamagui/use-direction": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/use-direction/-/use-direction-1.139.2.tgz",
"integrity": "sha512-y/eVQ1KEfxO3AaiN61EHGOtibyCHGH8Pg9mdvWFk15fznrwlhHmMw+YmbcL6ul4MTDnhFmkwBfmvmrtavs/Yeg==",
"peerDependencies": {
"react": "*"
}
},
"node_modules/@tamagui/use-element-layout": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/use-element-layout/-/use-element-layout-1.139.2.tgz",
@@ -5944,6 +6192,14 @@
"react": "*"
}
},
"node_modules/@tamagui/use-previous": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/use-previous/-/use-previous-1.139.2.tgz",
"integrity": "sha512-Q5Z71StUgSfJ6+KOUMzYGYmxRRa5J48ZwVvFgyyKV2Vl9uPoCC16JO4Xs8U69F1Cp0k77bFWKgc06He+gG/DXg==",
"peerDependencies": {
"react": "*"
}
},
"node_modules/@tamagui/vite-plugin": {
"version": "1.139.2",
"resolved": "https://registry.npmjs.org/@tamagui/vite-plugin/-/vite-plugin-1.139.2.tgz",

View File

@@ -67,8 +67,10 @@
"@tailwindcss/vite": "^4.1.11",
"@tamagui/button": "^1.139.2",
"@tamagui/config": "^1.139.2",
"@tamagui/font": "^1.139.3",
"@tamagui/group": "^1.139.2",
"@tamagui/list-item": "^1.139.2",
"@tamagui/radio-group": "1.139.2",
"@tamagui/stacks": "^1.139.2",
"@tamagui/text": "^1.139.2",
"@tamagui/themes": "^1.139.2",

View File

@@ -970,10 +970,14 @@ function normalizeTask(task: JsonValue): TenantTask {
? Number(task.event_type_id)
: eventType?.id ?? null;
const emotionRaw = task.emotion ?? null;
const rawId =
task.id ??
(task as { task_id?: unknown }).task_id ??
((task as { pivot?: { task_id?: unknown } }).pivot?.task_id ?? null);
return {
id: Number(task.id ?? 0),
slug: String(task.slug ?? `task-${task.id ?? ''}`),
id: Number(rawId ?? 0),
slug: String(task.slug ?? `task-${rawId ?? task.id ?? ''}`),
title: pickTranslatedText(titleTranslations, 'Ohne Titel'),
title_translations: titleTranslations,
description: Object.keys(descriptionTranslations).length

View File

@@ -19,7 +19,11 @@ declare global {
}
}
export function DevTenantSwitcher() {
type DevTenantSwitcherProps = {
bottomOffset?: number;
};
export function DevTenantSwitcher({ bottomOffset = 16 }: DevTenantSwitcherProps) {
const helper = window.fotospielDemoAuth;
const [loggingIn, setLoggingIn] = React.useState<string | null>(null);
const [collapsed, setCollapsed] = React.useState<boolean>(() => {
@@ -55,7 +59,8 @@ export function DevTenantSwitcher() {
return (
<button
type="button"
className="pointer-events-auto fixed bottom-4 right-4 z-[1000] flex items-center gap-2 rounded-full border border-amber-200 bg-white/95 px-4 py-2 text-sm font-medium text-amber-700 shadow-lg shadow-amber-200/60 transition hover:bg-amber-50"
className="pointer-events-auto fixed right-4 z-[1000] flex items-center gap-2 rounded-full border border-amber-200 bg-white/95 px-4 py-2 text-sm font-medium text-amber-700 shadow-lg shadow-amber-200/60 transition hover:bg-amber-50"
style={{ bottom: bottomOffset }}
onClick={() => setCollapsed(false)}
>
<PanelRightOpen className="h-4 w-4" />
@@ -76,7 +81,10 @@ export function DevTenantSwitcher() {
}
return (
<div className="pointer-events-auto fixed bottom-4 right-4 z-[1000] flex max-w-xs flex-col gap-2 rounded-xl border border-amber-200 bg-white/95 p-3 text-sm shadow-xl shadow-amber-200/60">
<div
className="pointer-events-auto fixed right-4 z-[1000] flex max-w-xs flex-col gap-2 rounded-xl border border-amber-200 bg-white/95 p-3 text-sm shadow-xl shadow-amber-200/60"
style={{ bottom: bottomOffset }}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<strong className="text-amber-800">Demo tenants</strong>

View File

@@ -1607,5 +1607,168 @@
"cta": "Galerie öffnen",
"ctaFallback": "Events ansehen"
}
},
"events": {
"errors": {
"missingSlug": "Kein Event-Slug angegeben.",
"loadFailed": "Tasks konnten nicht geladen werden.",
"saveFailed": "Task konnte nicht gespeichert werden."
},
"tasks": {
"title": "Tasks & Checklisten",
"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.",
"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": "Mehrere hinzufügen",
"manageEmotions": "Emotionen verwalten",
"manageEmotionsHint": "Filter pflegen und deine Taxonomie sauber halten.",
"saveEmotion": "Emotion speichern",
"emotionName": "Name",
"emotionNamePlaceholder": "z.B. Freude",
"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. Diese werden erstellt und dem Event hinzugefügt.",
"bulkPlaceholder": "z.B.\nBraut & Bräutigam Portrait\nGruppenfoto Hauptgäste"
}
},
"mobileDashboard": {
"title": "Dashboard",
"selectEvent": "Wähle ein Event, um fortzufahren",
"emptyTitle": "Erstelle dein erstes Event",
"emptyBody": "Starte ein Event, um Tasks, QR-Poster und Uploads zu verwalten.",
"ctaCreate": "Event erstellen",
"ctaDemo": "Demo ansehen",
"highlightsTitle": "Das kannst du tun",
"highlightImages": "Fotos & Uploads prüfen",
"highlightTasks": "Tasks & Challenges zuweisen",
"highlightQr": "QR-Poster teilen",
"highlightGuests": "Helfer & Gäste einladen",
"pickEvent": "Event auswählen",
"status": {
"published": "Live",
"draft": "Entwurf"
},
"photosLabel": "Fotos prüfen",
"photosDesc": "Uploads und Highlights moderieren",
"tasksLabel": "Tasks & Challenges verwalten",
"tasksDesc": "Zuweisen und Fortschritt verfolgen",
"qrLabel": "QR-Code anzeigen/teilen",
"qrDesc": "Poster, Karten und Links",
"shortcutsTitle": "Shortcuts",
"shortcutGuests": "Gästeverwaltung",
"shortcutPrints": "Drucke & Poster-Downloads",
"shortcutInvites": "Team-/Helfer-Einladungen",
"shortcutSettings": "Event-Einstellungen",
"kpiTitle": "Wichtigste Kennzahlen",
"kpiTasks": "Offene Tasks",
"kpiPhotos": "Fotos",
"kpiGuests": "Gäste",
"alertsTitle": "Hinweise",
"alertPending": "{{count}} neue Uploads warten auf Freigabe",
"alertTasks": "{{count}} Tasks offen oder fällig"
},
"mobileUploads": {
"title": "Uploads",
"emptyTitle": "Lege zuerst ein Event an",
"emptyBody": "Füge dein erstes Event hinzu, um Uploads zu prüfen und QR-Sharing zu steuern.",
"pickEvent": "Wähle ein Event, um Uploads zu verwalten",
"open": "Öffnen"
},
"mobilePhotos": {
"title": "Foto-Moderation",
"empty": "Keine Fotos gefunden.",
"count": "{{count}} Fotos",
"filtersTitle": "Filter",
"applyFilters": "Filter anwenden",
"uploader": "Uploader",
"uploaderPlaceholder": "Name oder E-Mail",
"onlyFeatured": "Nur Highlights",
"onlyHidden": "Nur versteckte",
"loadFailed": "Fotos konnten nicht geladen werden.",
"hideSuccess": "Foto versteckt",
"showSuccess": "Foto eingeblendet",
"visibilityFailed": "Sichtbarkeit konnte nicht geändert werden.",
"featureSuccess": "Als Highlight markiert",
"unfeatureSuccess": "Highlight entfernt",
"featureFailed": "Highlight konnte nicht geändert werden."
},
"mobileProfile": {
"title": "Profil",
"settings": "Einstellungen",
"account": "Account & Sicherheit",
"language": "Sprache",
"languageDe": "Deutsch",
"languageEn": "Englisch",
"theme": "Theme",
"themeLight": "Hell",
"themeDark": "Dunkel",
"themeSystem": "System",
"logout": "Abmelden"
},
"mobileSettings": {
"title": "Einstellungen",
"accountTitle": "Account",
"tenantBadge": "Tenant #{{id}}",
"notificationsTitle": "Benachrichtigungen",
"notificationsLoading": "Lade Einstellungen ...",
"pref": {
"task_updates": "Task-Updates",
"photo_limits": "Foto-Limits",
"photo_thresholds": "Foto-Schwellen",
"guest_limits": "Gäste-Limits",
"guest_thresholds": "Gäste-Schwellen",
"purchase_limits": "Kauf-Limits",
"billing": "Abrechnung & Rechnungen",
"alerts": "Warnungen"
}
},
"mobileBilling": {
"packageFallback": "Paket",
"remainingEvents": "{{count}} Events",
"status": {
"completed": "Abgeschlossen",
"pending": "Ausstehend",
"failed": "Fehlgeschlagen"
},
"extra": {
"photos": "+{{count}} Fotos",
"guests": "+{{count}} Gäste",
"days": "+{{count}} Tage"
}
},
"mobileNotifications": {
"title": "Benachrichtigungen",
"empty": "Keine Benachrichtigungen vorhanden.",
"filterByEvent": "Nach Event filtern"
}
}

View File

@@ -4,7 +4,7 @@
"tasks": "Aufgaben",
"uploads": "Uploads",
"profile": "Profil",
"alerts": "Alerts",
"notifications": "Benachrichtigungen",
"events": "Events"
},
"actions": {

View File

@@ -1630,5 +1630,168 @@
"cta": "Open gallery",
"ctaFallback": "View events"
}
},
"events": {
"errors": {
"missingSlug": "No event slug provided.",
"loadFailed": "Tasks could not be loaded.",
"saveFailed": "Task could not be saved."
},
"tasks": {
"title": "Tasks & checklists",
"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.",
"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"
}
},
"mobileDashboard": {
"title": "Dashboard",
"selectEvent": "Select an event to continue",
"emptyTitle": "Create your first event",
"emptyBody": "Start an event to manage tasks, QR posters and uploads.",
"ctaCreate": "Create event",
"ctaDemo": "View demo",
"highlightsTitle": "What you can do",
"highlightImages": "Review photos & uploads",
"highlightTasks": "Assign tasks & challenges",
"highlightQr": "Share QR posters",
"highlightGuests": "Invite helpers & guests",
"pickEvent": "Select an event",
"status": {
"published": "Live",
"draft": "Draft"
},
"photosLabel": "Review photos",
"photosDesc": "Moderate uploads and highlights",
"tasksLabel": "Manage tasks & challenges",
"tasksDesc": "Assign and track progress",
"qrLabel": "Show / share QR code",
"qrDesc": "Posters, cards, and links",
"shortcutsTitle": "Shortcuts",
"shortcutGuests": "Guest management",
"shortcutPrints": "Print & poster downloads",
"shortcutInvites": "Team / helper invites",
"shortcutSettings": "Event settings",
"kpiTitle": "Key performance indicators",
"kpiTasks": "Open tasks",
"kpiPhotos": "Photos",
"kpiGuests": "Guests",
"alertsTitle": "Alerts",
"alertPending": "{{count}} new uploads awaiting moderation",
"alertTasks": "{{count}} tasks due or open"
},
"mobileUploads": {
"title": "Uploads",
"emptyTitle": "Create an event first",
"emptyBody": "Add your first event to review uploads and manage QR sharing.",
"pickEvent": "Pick an event to manage uploads",
"open": "Open"
},
"mobilePhotos": {
"title": "Photo moderation",
"empty": "No photos found.",
"count": "{{count}} photos",
"filtersTitle": "Filter",
"applyFilters": "Apply filters",
"uploader": "Uploader",
"uploaderPlaceholder": "Name or email",
"onlyFeatured": "Only featured",
"onlyHidden": "Only hidden",
"loadFailed": "Photos could not be loaded.",
"hideSuccess": "Photo hidden",
"showSuccess": "Photo shown",
"visibilityFailed": "Visibility could not be changed.",
"featureSuccess": "Marked as highlight",
"unfeatureSuccess": "Highlight removed",
"featureFailed": "Highlight could not be changed"
},
"mobileProfile": {
"title": "Profile",
"settings": "Settings",
"account": "Account & security",
"language": "Language",
"languageDe": "Deutsch",
"languageEn": "English",
"theme": "Theme",
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System",
"logout": "Log out"
},
"mobileSettings": {
"title": "Settings",
"accountTitle": "Account",
"tenantBadge": "Tenant #{{id}}",
"notificationsTitle": "Notifications",
"notificationsLoading": "Loading settings ...",
"pref": {
"task_updates": "Task updates",
"photo_limits": "Photo limits",
"photo_thresholds": "Photo thresholds",
"guest_limits": "Guest limits",
"guest_thresholds": "Guest thresholds",
"purchase_limits": "Purchase limits",
"billing": "Billing & invoices",
"alerts": "Alerts"
}
},
"mobileBilling": {
"packageFallback": "Package",
"remainingEvents": "{{count}} events",
"status": {
"completed": "Completed",
"pending": "Pending",
"failed": "Failed"
},
"extra": {
"photos": "+{{count}} photos",
"guests": "+{{count}} guests",
"days": "+{{count}} days"
}
},
"mobileNotifications": {
"title": "Notifications",
"empty": "No notifications yet.",
"filterByEvent": "Filter by event"
}
}

View File

@@ -4,7 +4,7 @@
"tasks": "Tasks",
"uploads": "Uploads",
"profile": "Profile",
"alerts": "Alerts",
"notifications": "Notifications",
"events": "Events"
},
"actions": {

View File

@@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { TamaguiProvider } from '@tamagui/core';
import { TamaguiProvider, Theme } from '@tamagui/core';
import '@tamagui/core/reset.css';
import tamaguiConfig from '../../../tamagui.config';
import { AuthProvider } from './auth/context';
@@ -11,7 +11,7 @@ import { router } from './router';
import '../../css/app.css';
import './i18n';
import './dev-tools';
import { initializeTheme } from '@/hooks/use-appearance';
import { AppearanceProvider, useAppearance, initializeTheme } from '@/hooks/use-appearance';
import { OnboardingProgressProvider } from './onboarding';
import { EventProvider } from './context/EventContext';
import MatomoTracker from '@/components/analytics/MatomoTracker';
@@ -46,7 +46,19 @@ if ('serviceWorker' in navigator) {
createRoot(rootEl).render(
<React.StrictMode>
<TamaguiProvider config={tamaguiConfig} defaultTheme="light">
<AppearanceProvider>
<AdminApp />
</AppearanceProvider>
</React.StrictMode>
);
function AdminApp() {
const { resolved } = useAppearance();
const themeName = resolved ?? 'light';
return (
<TamaguiProvider config={tamaguiConfig} defaultTheme={themeName} themeClassNameOnRoot>
<Theme name={themeName}>
<ConsentProvider>
<QueryClientProvider client={queryClient}>
<AuthProvider>
@@ -76,6 +88,7 @@ createRoot(rootEl).render(
) : null}
</QueryClientProvider>
</ConsentProvider>
</Theme>
</TamaguiProvider>
</React.StrictMode>
);
);
}

View File

@@ -190,13 +190,14 @@ export default function MobileBillingPage() {
}
function PackageCard({ pkg, label }: { pkg: TenantPackageSummary; label?: string }) {
const { t } = useTranslation('management');
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;
return (
<MobileCard borderColor="#e5e7eb" space="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{pkg.package_name ?? 'Package'}
{pkg.package_name ?? t('mobileBilling.packageFallback', 'Package')}
</Text>
{label ? <PillBadge tone="success">{label}</PillBadge> : null}
</XStack>
@@ -207,7 +208,7 @@ function PackageCard({ pkg, label }: { pkg: TenantPackageSummary; label?: string
) : null}
<XStack space="$2" marginTop="$2">
<PillBadge tone="muted">
{remaining} Events
{t('mobileBilling.remainingEvents', '{{count}} events', { count: remaining })}
</PillBadge>
{pkg.price !== null && pkg.price !== undefined ? (
<PillBadge tone="muted">{formatAmount(pkg.price, pkg.currency ?? 'EUR')}</PillBadge>
@@ -230,10 +231,11 @@ function formatAmount(value: number | null | undefined, currency: string | null
}
function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
const { t } = useTranslation('management');
const labels: Record<TenantAddonHistoryEntry['status'], { tone: 'success' | 'warning' | 'muted'; text: string }> = {
completed: { tone: 'success', text: 'Completed' },
pending: { tone: 'warning', text: 'Pending' },
failed: { tone: 'muted', text: 'Failed' },
completed: { tone: 'success', text: t('mobileBilling.status.completed', 'Completed') },
pending: { tone: 'warning', text: t('mobileBilling.status.pending', 'Pending') },
failed: { tone: 'muted', text: t('mobileBilling.status.failed', 'Failed') },
};
const status = labels[addon.status];
const eventName =
@@ -258,9 +260,15 @@ function AddonRow({ addon }: { addon: TenantAddonHistoryEntry }) {
</Text>
) : null}
<XStack space="$2" marginTop="$1">
{addon.extra_photos ? <PillBadge tone="muted">+{addon.extra_photos} photos</PillBadge> : null}
{addon.extra_guests ? <PillBadge tone="muted">+{addon.extra_guests} guests</PillBadge> : null}
{addon.extra_gallery_days ? <PillBadge tone="muted">+{addon.extra_gallery_days} days</PillBadge> : null}
{addon.extra_photos ? (
<PillBadge tone="muted">{t('mobileBilling.extra.photos', '+{{count}} photos', { count: addon.extra_photos })}</PillBadge>
) : null}
{addon.extra_guests ? (
<PillBadge tone="muted">{t('mobileBilling.extra.guests', '+{{count}} guests', { count: addon.extra_guests })}</PillBadge>
) : null}
{addon.extra_gallery_days ? (
<PillBadge tone="muted">{t('mobileBilling.extra.days', '+{{count}} days', { count: addon.extra_gallery_days })}</PillBadge>
) : null}
</XStack>
<Text fontSize="$sm" color="#0f172a" marginTop="$1">
{formatAmount(addon.amount, addon.currency)}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Image as ImageIcon, RefreshCcw, Save } from 'lucide-react';
import { Image as ImageIcon, RefreshCcw, UploadCloud, Trash2, ChevronDown, Save } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
@@ -9,14 +9,16 @@ import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { TenantEvent, getEvent, updateEvent, getTenantFonts, TenantFont } from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { ApiError, getApiErrorMessage } from '../lib/apiError';
import { MobileSheet } from './components/Sheet';
import toast from 'react-hot-toast';
type BrandingForm = {
primary: string;
accent: string;
headingFont: string;
bodyFont: string;
logoDataUrl: string;
};
export default function MobileBrandingPage() {
@@ -31,13 +33,16 @@ export default function MobileBrandingPage() {
accent: '#5AD2F4',
headingFont: '',
bodyFont: '',
logoDataUrl: '',
});
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [showFontsSheet, setShowFontsSheet] = React.useState(false);
const [fontField, setFontField] = React.useState<'heading' | 'body'>('heading');
const [fonts, setFonts] = React.useState<TenantFont[]>([]);
const [fontsLoading, setFontsLoading] = React.useState(false);
const [fontsLoaded, setFontsLoaded] = React.useState(false);
React.useEffect(() => {
if (!slug) return;
@@ -59,26 +64,41 @@ export default function MobileBrandingPage() {
}, [slug, t]);
React.useEffect(() => {
(async () => {
if (!showFontsSheet || fontsLoaded) return;
setFontsLoading(true);
try {
const data = await getTenantFonts();
setFonts(data ?? []);
} catch {
// non-fatal
} finally {
getTenantFonts()
.then((data) => setFonts(data ?? []))
.catch(() => undefined)
.finally(() => {
setFontsLoading(false);
}
})();
}, []);
setFontsLoaded(true);
});
}, [showFontsSheet, fontsLoaded]);
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const previewHeadingFont = form.headingFont || 'Montserrat';
const previewBodyFont = form.bodyFont || 'Montserrat';
async function handleSave() {
if (!event?.slug) return;
const eventTypeId = event.event_type_id ?? event.event_type?.id ?? null;
if (!eventTypeId) {
const msg = t('events.errors.missingType', 'Event type fehlt. Speichere das Event erneut im Admin.');
setError(msg);
toast.error(msg);
return;
}
setSaving(true);
setError(null);
try {
const payload = {
name: typeof event.name === 'string' ? event.name : renderName(event.name),
slug: event.slug,
event_type_id: eventTypeId,
event_date: event.event_date ?? undefined,
status: event.status ?? 'draft',
is_active: event.is_active ?? undefined,
};
const settings = { ...(event.settings ?? {}) };
settings.branding = {
...(typeof settings.branding === 'object' ? (settings.branding as Record<string, unknown>) : {}),
@@ -86,12 +106,49 @@ export default function MobileBrandingPage() {
accent_color: form.accent,
heading_font: form.headingFont,
body_font: form.bodyFont,
typography: {
...(typeof (settings.branding as Record<string, unknown> | undefined)?.typography === 'object'
? ((settings.branding as Record<string, unknown>).typography as Record<string, unknown>)
: {}),
heading: form.headingFont,
body: form.bodyFont,
},
palette: {
...(typeof (settings.branding as Record<string, unknown> | undefined)?.palette === 'object'
? ((settings.branding as Record<string, unknown>).palette as Record<string, unknown>)
: {}),
primary: form.primary,
secondary: form.accent,
},
logo_data_url: form.logoDataUrl || null,
logo: form.logoDataUrl
? {
mode: 'upload',
value: form.logoDataUrl,
position: 'center',
size: 'm',
}
: null,
};
const updated = await updateEvent(event.slug, { settings });
const updated = await updateEvent(event.slug, {
...payload,
settings,
});
setEvent(updated);
toast.success(t('events.branding.saveSuccess', 'Branding gespeichert'));
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Branding konnte nicht gespeichert werden.')));
let message = getApiErrorMessage(err, t('events.errors.saveFailed', 'Branding konnte nicht gespeichert werden.'));
if (err instanceof ApiError && err.meta?.errors && typeof err.meta.errors === 'object') {
const allErrors = Object.values(err.meta.errors as Record<string, unknown>)
.flat()
.filter((val): val is string => typeof val === 'string');
if (allErrors.length) {
message = allErrors.join('\n');
}
}
setError(message);
toast.error(message, { duration: 5000 });
}
} finally {
setSaving(false);
@@ -107,7 +164,7 @@ export default function MobileBrandingPage() {
return (
<MobileShell
activeTab="home"
title={t('events.branding.title', 'Branding & Customization')}
title={t('events.branding.titleShort', 'Branding')}
onBack={() => navigate(-1)}
headerActions={
<Pressable disabled={saving} onPress={() => handleSave()}>
@@ -131,10 +188,10 @@ export default function MobileBrandingPage() {
<YStack width="100%" borderRadius={12} backgroundColor="white" borderWidth={1} borderColor="#e5e7eb" overflow="hidden">
<YStack backgroundColor={form.primary} height={64} />
<YStack padding="$3" space="$1.5">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color="#111827" style={{ fontFamily: previewHeadingFont }}>
{previewTitle}
</Text>
<Text fontSize="$sm" color="#4b5563">
<Text fontSize="$sm" color="#4b5563" style={{ fontFamily: previewBodyFont }}>
{t('events.branding.previewSubtitle', 'Aktuelle Farben & Schriften')}
</Text>
<XStack space="$2" marginTop="$1">
@@ -171,21 +228,21 @@ export default function MobileBrandingPage() {
value={form.headingFont}
placeholder="SF Pro Display"
onChange={(value) => setForm((prev) => ({ ...prev, headingFont: value }))}
onPicker={() => {
setFontField('heading');
setShowFontsSheet(true);
}}
/>
<InputField
label={t('events.branding.bodyFont', 'Body Font')}
value={form.bodyFont}
placeholder="SF Pro Text"
onChange={(value) => setForm((prev) => ({ ...prev, bodyFont: value }))}
onPicker={() => {
setFontField('body');
setShowFontsSheet(true);
}}
/>
<Pressable onPress={() => setShowFontsSheet(true)}>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
{t('events.branding.chooseFont', 'Choose from installed fonts')}
</Text>
<Save size={16} color="#007AFF" />
</XStack>
</Pressable>
</MobileCard>
<MobileCard space="$3">
@@ -202,10 +259,83 @@ export default function MobileBrandingPage() {
justifyContent="center"
space="$2"
>
<ImageIcon size={28} color="#94a3b8" />
<Text fontSize="$sm" color="#4b5563">
{t('events.branding.logoHint', 'Logo Upload folgt nutze Farben/Schriften.')}
{form.logoDataUrl ? (
<>
<img src={form.logoDataUrl} alt="Logo" style={{ maxHeight: 80, maxWidth: '100%', objectFit: 'contain' }} />
<XStack space="$2">
<CTAButton
label={t('events.branding.replaceLogo', 'Replace logo')}
onPress={() => document.getElementById('branding-logo-input')?.click()}
/>
<Pressable onPress={() => setForm((prev) => ({ ...prev, logoDataUrl: '' }))}>
<XStack
alignItems="center"
space="$1.5"
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={12}
borderWidth={1}
borderColor="#e5e7eb"
>
<Trash2 size={16} color="#b91c1c" />
<Text fontSize="$sm" color="#b91c1c" fontWeight="700">
{t('events.branding.removeLogo', 'Remove')}
</Text>
</XStack>
</Pressable>
</XStack>
</>
) : (
<>
<ImageIcon size={28} color="#94a3b8" />
<Text fontSize="$sm" color="#4b5563" 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()}>
<XStack
alignItems="center"
space="$2"
paddingHorizontal="$3.5"
paddingVertical="$2.5"
borderRadius={12}
borderWidth={1}
borderColor="#e5e7eb"
backgroundColor="white"
>
<UploadCloud size={18} color="#007AFF" />
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
{t('events.branding.uploadLogo', 'Upload logo (max. 1 MB)')}
</Text>
</XStack>
</Pressable>
</>
)}
<input
id="branding-logo-input"
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
if (file.size > 1024 * 1024) {
setError(t('events.branding.logoTooLarge', 'Logo must be under 1 MB.'));
return;
}
const reader = new FileReader();
reader.onload = () => {
const nextLogo =
typeof reader.result === 'string'
? reader.result
: typeof reader.result === 'object' && reader.result !== null
? String(reader.result)
: '';
setForm((prev) => ({ ...prev, logoDataUrl: nextLogo }));
setError(null);
};
reader.readAsDataURL(file);
}}
/>
</YStack>
</MobileCard>
@@ -249,22 +379,25 @@ export default function MobileBrandingPage() {
<Pressable
key={font.family}
onPress={() => {
setForm((prev) => ({ ...prev, headingFont: font.family, bodyFont: font.family }));
setForm((prev) => ({
...prev,
[fontField === 'heading' ? 'headingFont' : 'bodyFont']: font.family,
}));
setShowFontsSheet(false);
}}
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack>
<Text fontSize="$sm" color="#111827">
<Text fontSize="$sm" color="#111827" style={{ fontFamily: font.family }}>
{font.family}
</Text>
{font.variants?.length ? (
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color="#6b7280" style={{ fontFamily: font.family }}>
{font.variants.map((v) => v.style ?? v.weight ?? '').filter(Boolean).join(', ')}
</Text>
) : null}
</YStack>
{form.headingFont === font.family || form.bodyFont === font.family ? (
{form[fontField === 'heading' ? 'headingFont' : 'bodyFont'] === font.family ? (
<Text fontSize="$xs" color="#007AFF">
{t('common.active', 'Active')}
</Text>
@@ -295,6 +428,7 @@ function extractBranding(event: TenantEvent): BrandingForm {
accent: readColor('accent_color', '#5AD2F4'),
headingFont: readText('heading_font'),
bodyFont: readText('body_font'),
logoDataUrl: readText('logo_data_url'),
};
}
@@ -343,31 +477,51 @@ function InputField({
value,
placeholder,
onChange,
onPicker,
}: {
label: string;
value: string;
placeholder?: string;
onChange: (next: string) => void;
onPicker?: () => void;
}) {
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="700" color="#111827">
{label}
</Text>
<XStack
alignItems="center"
borderRadius={12}
borderWidth={1}
borderColor="#e5e7eb"
paddingLeft="$3"
paddingRight="$2"
height={48}
backgroundColor="white"
space="$2"
>
<input
type="text"
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
style={{
width: '100%',
height: 48,
borderRadius: 12,
border: '1px solid #e5e7eb',
padding: '0 12px',
flex: 1,
height: '100%',
border: 'none',
outline: 'none',
fontSize: 14,
background: 'transparent',
}}
onFocus={onPicker}
/>
{onPicker ? (
<Pressable onPress={onPicker}>
<ChevronDown size={16} color="#007AFF" />
</Pressable>
) : null}
</XStack>
</YStack>
);
}

View File

@@ -12,6 +12,7 @@ import { adminPath } from '../constants';
import { useEventContext } from '../context/EventContext';
import { getEventStats, EventStats, TenantEvent, getEvents } from '../api';
import { formatEventDate, resolveEventDisplayName } from '../lib/events';
import { useTheme } from '@tamagui/core';
export default function MobileDashboardPage() {
const navigate = useNavigate();
@@ -20,6 +21,13 @@ export default function MobileDashboardPage() {
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [fallbackLoading, setFallbackLoading] = React.useState(false);
const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
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 { data: stats, isLoading: statsLoading } = useQuery<EventStats | null>({
queryKey: ['mobile', 'dashboard', 'stats', activeEvent?.slug],
@@ -62,7 +70,7 @@ export default function MobileDashboardPage() {
if (isLoading || fallbackLoading) {
return (
<MobileShell activeTab="home" title={t('events.list.dashboardTitle', 'Dashboard')}>
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
<YStack space="$2">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`sk-${idx}`} height={110} opacity={0.6} />
@@ -74,7 +82,7 @@ export default function MobileDashboardPage() {
if (!effectiveHasEvents) {
return (
<MobileShell activeTab="home" title={t('events.list.dashboardTitle', 'Dashboard')}>
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
<OnboardingEmptyState />
</MobileShell>
);
@@ -84,10 +92,10 @@ export default function MobileDashboardPage() {
return (
<MobileShell
activeTab="home"
title={t('events.list.dashboardTitle', 'Dashboard')}
subtitle={t('header.selectEvent', 'Select an event to continue')}
title={t('mobileDashboard.title', 'Dashboard')}
subtitle={t('mobileDashboard.selectEvent', 'Select an event to continue')}
>
<EventPickerList events={effectiveEvents} locale={locale} />
<EventPickerList events={effectiveEvents} locale={locale} text={text} muted={muted} border={border} />
</MobileShell>
);
}
@@ -122,28 +130,31 @@ export default function MobileDashboardPage() {
function OnboardingEmptyState() {
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');
return (
<YStack space="$3">
<MobileCard alignItems="flex-start" space="$3">
<Text fontSize="$lg" fontWeight="800" color="#111827">
{t('events.list.empty.title', 'Create your first event')}
<Text fontSize="$lg" fontWeight="800" color={text}>
{t('mobileDashboard.emptyTitle', 'Create your first event')}
</Text>
<Text fontSize="$sm" color="#4b5563">
{t('events.list.empty.description', 'Start an event to manage tasks, QR posters and uploads.')}
<Text fontSize="$sm" color={muted}>
{t('mobileDashboard.emptyBody', 'Start an event to manage tasks, QR posters and uploads.')}
</Text>
<CTAButton label={t('events.actions.create', 'Create Event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
<CTAButton label={t('events.actions.preview', 'View Demo')} tone="ghost" onPress={() => navigate(adminPath('/mobile/events'))} />
<CTAButton label={t('mobileDashboard.ctaCreate', 'Create event')} onPress={() => navigate(adminPath('/mobile/events/new'))} />
<CTAButton label={t('mobileDashboard.ctaDemo', 'View demo')} tone="ghost" onPress={() => navigate(adminPath('/mobile/events'))} />
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="800" color="#111827">
{t('events.list.empty.highlights', 'What you can do')}
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.highlightsTitle', 'What you can do')}
</Text>
<YStack space="$1.5">
{[
t('events.quick.images', 'Review photos & uploads'),
t('events.quick.tasks', 'Assign tasks & challenges'),
t('events.quick.qr', 'Share QR posters'),
t('events.quick.guests', 'Invite helpers & guests'),
t('mobileDashboard.highlightImages', 'Review photos & uploads'),
t('mobileDashboard.highlightTasks', 'Assign tasks & challenges'),
t('mobileDashboard.highlightQr', 'Share QR posters'),
t('mobileDashboard.highlightGuests', 'Invite helpers & guests'),
].map((item) => (
<XStack key={item} alignItems="center" space="$2">
<PillBadge tone="muted">{item}</PillBadge>
@@ -155,7 +166,7 @@ function OnboardingEmptyState() {
);
}
function EventPickerList({ events, locale }: { events: TenantEvent[]; locale: string }) {
function EventPickerList({ events, locale, text, muted, border }: { events: TenantEvent[]; locale: string; text: string; muted: string; border: string }) {
const { t } = useTranslation('management');
const { selectEvent } = useEventContext();
const navigate = useNavigate();
@@ -179,8 +190,8 @@ function EventPickerList({ events, locale }: { events: TenantEvent[]; locale: st
return (
<YStack space="$2">
<Text fontSize="$sm" color="#111827" fontWeight="700">
{t('events.detail.pickEvent', 'Select an event')}
<Text fontSize="$sm" color={text} fontWeight="700">
{t('mobileDashboard.pickEvent', 'Select an event')}
</Text>
{localEvents.map((event) => (
<Pressable
@@ -192,18 +203,20 @@ function EventPickerList({ events, locale }: { events: TenantEvent[]; locale: st
}
}}
>
<MobileCard borderColor="#e5e7eb" space="$2">
<MobileCard borderColor={border} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={text}>
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color="#6b7280">
{formatEventDate(event.event_date, locale) ?? t('events.status.draft', 'Draft')}
<Text fontSize="$xs" color={muted}>
{formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')}
</Text>
</YStack>
<PillBadge tone={event.status === 'published' ? 'success' : 'warning'}>
{event.status === 'published' ? t('events.status.published', 'Live') : t('events.status.draft', 'Draft')}
{event.status === 'published'
? t('mobileDashboard.status.published', 'Live')
: t('mobileDashboard.status.draft', 'Draft')}
</PillBadge>
</XStack>
</MobileCard>
@@ -223,27 +236,30 @@ 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 cards = [
{
key: 'photos',
label: t('events.quick.images', 'Review Photos'),
desc: t('events.quick.images.desc', 'Moderate uploads and highlights'),
label: t('mobileDashboard.photosLabel', 'Review photos'),
desc: t('mobileDashboard.photosDesc', 'Moderate uploads and highlights'),
icon: ImageIcon,
color: '#0ea5e9',
action: onReviewPhotos,
},
{
key: 'tasks',
label: t('events.quick.tasks', 'Manage Tasks & Challenges'),
desc: t('events.quick.tasks.desc', 'Assign and track progress'),
label: t('mobileDashboard.tasksLabel', 'Manage tasks & challenges'),
desc: t('mobileDashboard.tasksDesc', 'Assign and track progress'),
icon: ListTodo,
color: '#22c55e',
action: onManageTasks,
},
{
key: 'qr',
label: t('events.quick.qr', 'Show / Share QR Code'),
desc: t('events.quick.qr.desc', 'Posters, cards, and links'),
label: t('mobileDashboard.qrLabel', 'Show / share QR code'),
desc: t('mobileDashboard.qrDesc', 'Posters, cards, and links'),
icon: QrCode,
color: '#f59e0b',
action: onShowQr,
@@ -260,14 +276,14 @@ function FeaturedActions({
<card.icon size={20} color="white" />
</XStack>
<YStack space="$1" flex={1}>
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={text}>
{card.label}
</Text>
<Text fontSize="$xs" color="#334155">
<Text fontSize="$xs" color={muted}>
{card.desc}
</Text>
</YStack>
<Text fontSize="$xl" color="#94a3b8">
<Text fontSize="$xl" color={String(theme.gray9?.val ?? '#94a3b8')}>
˃
</Text>
</XStack>
@@ -292,28 +308,33 @@ 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 tiles = [
{
icon: Users,
label: t('events.quick.guests', 'Guest management'),
label: t('mobileDashboard.shortcutGuests', 'Guest management'),
color: '#60a5fa',
action: onGuests,
},
{
icon: QrCode,
label: t('events.quick.prints', 'Print & poster downloads'),
label: t('mobileDashboard.shortcutPrints', 'Print & poster downloads'),
color: '#fbbf24',
action: onPrint,
},
{
icon: Sparkles,
label: t('events.quick.invites', 'Team / helper invites'),
label: t('mobileDashboard.shortcutInvites', 'Team / helper invites'),
color: '#a855f7',
action: onInvites,
},
{
icon: Settings,
label: t('events.quick.settings', 'Event settings'),
label: t('mobileDashboard.shortcutSettings', 'Event settings'),
color: '#10b981',
action: onSettings,
},
@@ -321,8 +342,8 @@ function SecondaryGrid({
return (
<YStack space="$2" marginTop="$2">
<Text fontSize="$sm" fontWeight="800" color="#111827">
{t('events.quick.more', 'Shortcuts')}
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.shortcutsTitle', 'Shortcuts')}
</Text>
<XStack flexWrap="wrap" space="$2">
{tiles.map((tile) => (
@@ -330,11 +351,11 @@ function SecondaryGrid({
))}
</XStack>
{event ? (
<MobileCard backgroundColor="#f8fafc" borderColor="#e2e8f0" space="$1.5">
<Text fontSize="$sm" fontWeight="700" color="#0f172a">
<MobileCard backgroundColor={surface} borderColor={border} space="$1.5">
<Text fontSize="$sm" fontWeight="700" color={text}>
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color="#475569">
<Text fontSize="$xs" color={muted}>
{renderEventLocation(event)}
</Text>
</MobileCard>
@@ -345,21 +366,24 @@ function SecondaryGrid({
function KpiStrip({ event, stats, loading, locale }: { event: TenantEvent | null; stats: EventStats | null | undefined; loading: boolean; locale: string }) {
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');
if (!event) return null;
const kpis = [
{
label: t('events.detail.kpi.tasks', 'Open tasks'),
label: t('mobileDashboard.kpiTasks', 'Open tasks'),
value: event.tasks_count ?? '—',
icon: ListTodo,
},
{
label: t('events.detail.kpi.photos', 'Photos'),
label: t('mobileDashboard.kpiPhotos', 'Photos'),
value: stats?.uploads_total ?? event.photo_count ?? '—',
icon: ImageIcon,
},
{
label: t('events.detail.kpi.guests', 'Guests'),
label: t('mobileDashboard.kpiGuests', 'Guests'),
value: event.active_invites_count ?? event.total_invites_count ?? '—',
icon: Users,
},
@@ -367,8 +391,8 @@ function KpiStrip({ event, stats, loading, locale }: { event: TenantEvent | null
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color="#111827">
{t('dashboard.kpis', 'Key Performance Indicators')}
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.kpiTitle', 'Key performance indicators')}
</Text>
{loading ? (
<XStack space="$2" flexWrap="wrap">
@@ -383,7 +407,7 @@ function KpiStrip({ event, stats, loading, locale }: { event: TenantEvent | null
))}
</XStack>
)}
<Text fontSize="$xs" color="#94a3b8">
<Text fontSize="$xs" color={muted}>
{formatEventDate(event.event_date, locale) ?? ''}
</Text>
</YStack>
@@ -392,14 +416,19 @@ function KpiStrip({ event, stats, loading, locale }: { event: TenantEvent | null
function AlertsAndHints({ event, stats }: { event: TenantEvent | null; stats: EventStats | null | undefined }) {
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');
if (!event) return null;
const alerts: string[] = [];
if (stats?.pending_photos) {
alerts.push(t('events.alerts.pendingPhotos', '{{count}} new uploads awaiting moderation', { count: stats.pending_photos }));
alerts.push(t('mobileDashboard.alertPending', '{{count}} new uploads awaiting moderation', { count: stats.pending_photos }));
}
if (event.tasks_count) {
alerts.push(t('events.alerts.tasksOpen', '{{count}} tasks due or open', { count: event.tasks_count }));
alerts.push(t('mobileDashboard.alertTasks', '{{count}} tasks due or open', { count: event.tasks_count }));
}
if (alerts.length === 0) {
@@ -408,12 +437,12 @@ function AlertsAndHints({ event, stats }: { event: TenantEvent | null; stats: Ev
return (
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color="#111827">
{t('alerts.title', 'Alerts')}
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.alertsTitle', 'Alerts')}
</Text>
{alerts.map((alert) => (
<MobileCard key={alert} backgroundColor="#fff7ed" borderColor="#fed7aa" space="$2">
<Text fontSize="$sm" color="#9a3412">
<MobileCard key={alert} backgroundColor={warningBg} borderColor={warningBorder} space="$2">
<Text fontSize="$sm" color={warningText}>
{alert}
</Text>
</MobileCard>

View File

@@ -95,7 +95,7 @@ export default function MobileEventDetailPage() {
onBack={() => navigate(-1)}
headerActions={
<XStack space="$3" alignItems="center">
<Pressable onPress={() => navigate(adminPath('/settings'))}>
<Pressable onPress={() => navigate(adminPath('/mobile/settings'))}>
<Settings size={18} color="#0f172a" />
</Pressable>
<Pressable onPress={() => navigate(0)}>

View File

@@ -13,6 +13,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';
type FilterKey = 'all' | 'featured' | 'hidden';
@@ -37,6 +38,29 @@ export default function MobileEventPhotosPage() {
const [onlyFeatured, setOnlyFeatured] = React.useState(false);
const [onlyHidden, setOnlyHidden] = React.useState(false);
const [lightbox, setLightbox] = React.useState<TenantPhoto | null>(null);
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 baseInputStyle = React.useMemo<React.CSSProperties>(
() => ({
width: '100%',
height: 38,
borderRadius: 10,
border: `1px solid ${border}`,
padding: '0 12px',
fontSize: 13,
background: surface,
color: text,
}),
[border, surface, text],
);
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
@@ -62,7 +86,7 @@ export default function MobileEventPhotosPage() {
setHasMore(page < lastPage);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Fotos konnten nicht geladen werden.')));
setError(getApiErrorMessage(err, t('mobilePhotos.loadFailed', 'Fotos konnten nicht geladen werden.')));
}
} finally {
setLoading(false);
@@ -83,15 +107,16 @@ export default function MobileEventPhotosPage() {
try {
const updated = await updatePhotoVisibility(slug, photo.id, photo.status === 'hidden');
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev));
toast.success(
updated.status === 'hidden'
? t('events.photos.hideSuccess', 'Foto versteckt')
: t('events.photos.showSuccess', 'Foto eingeblendet'),
? t('mobilePhotos.hideSuccess', 'Photo hidden')
: t('mobilePhotos.showSuccess', 'Photo shown'),
);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Sichtbarkeit konnte nicht geändert werden.')));
toast.error(t('events.photos.hideFailed', 'Sichtbarkeit konnte nicht geändert werden.'));
setError(getApiErrorMessage(err, t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.')));
toast.error(t('mobilePhotos.visibilityFailed', 'Sichtbarkeit konnte nicht geändert werden.'));
}
} finally {
setBusyId(null);
@@ -104,15 +129,16 @@ export default function MobileEventPhotosPage() {
try {
const updated = photo.is_featured ? await unfeaturePhoto(slug, photo.id) : await featurePhoto(slug, photo.id);
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev));
toast.success(
updated.is_featured
? t('events.photos.featureSuccess', 'Als Highlight markiert')
: t('events.photos.unfeatureSuccess', 'Highlight entfernt'),
? t('mobilePhotos.featureSuccess', 'Als Highlight markiert')
: t('mobilePhotos.unfeatureSuccess', 'Highlight entfernt'),
);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.saveFailed', 'Feature konnte nicht geändert werden.')));
toast.error(t('events.photos.featureFailed', 'Feature konnte nicht geändert werden.'));
setError(getApiErrorMessage(err, t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.')));
toast.error(t('mobilePhotos.featureFailed', 'Feature konnte nicht geändert werden.'));
}
} finally {
setBusyId(null);
@@ -122,22 +148,22 @@ export default function MobileEventPhotosPage() {
return (
<MobileShell
activeTab="uploads"
title={t('events.photos.title', 'Photo Moderation')}
title={t('mobilePhotos.title', 'Photo moderation')}
onBack={() => navigate(-1)}
headerActions={
<XStack space="$3">
<Pressable onPress={() => setShowFilters(true)}>
<Filter size={18} color="#0f172a" />
<Filter size={18} color={text} />
</Pressable>
<Pressable onPress={() => load()}>
<RefreshCcw size={18} color="#0f172a" />
<RefreshCcw size={18} color={text} />
</Pressable>
</XStack>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
@@ -150,29 +176,24 @@ export default function MobileEventPhotosPage() {
setSearch(e.target.value);
setPage(1);
}}
placeholder={t('events.photos.search', 'Search photos')}
style={{
width: '100%',
height: 38,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: 'white',
marginBottom: 12,
}}
placeholder={t('photos.filters.search', 'Search uploads …')}
style={{ ...baseInputStyle, marginBottom: 12 }}
/>
<XStack space="$2">
{(['all', 'featured', 'hidden'] as FilterKey[]).map((key) => (
<Pressable key={key} onPress={() => setFilter(key)} style={{ flex: 1 }}>
<MobileCard
backgroundColor={filter === key ? '#e8f1ff' : 'white'}
borderColor={filter === key ? '#bfdbfe' : '#e5e7eb'}
backgroundColor={filter === key ? infoBg : surface}
borderColor={filter === key ? infoBorder : border}
padding="$2.5"
>
<Text fontSize="$sm" fontWeight="700" textAlign="center" color="#111827">
{key === 'all' ? t('common.all', 'All') : key === 'featured' ? t('events.photos.featured', 'Featured') : t('events.photos.hidden', 'Hidden')}
<Text fontSize="$sm" fontWeight="700" textAlign="center" color={text}>
{key === 'all'
? t('common.all', 'All')
: key === 'featured'
? t('photos.filters.featured', 'Featured')
: t('photos.filters.hidden', 'Hidden')}
</Text>
</MobileCard>
</Pressable>
@@ -187,15 +208,15 @@ export default function MobileEventPhotosPage() {
</YStack>
) : photos.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<ImageIcon size={28} color="#9ca3af" />
<Text fontSize="$sm" color="#4b5563">
{t('events.photos.empty', 'Keine Fotos gefunden.')}
<ImageIcon size={28} color={muted} />
<Text fontSize="$sm" color={muted}>
{t('mobilePhotos.empty', 'No photos found.')}
</Text>
</MobileCard>
) : (
<YStack space="$3">
<Text fontSize="$sm" color="#4b5563">
{t('events.photos.count', '{{count}} Fotos', { count: totalCount })}
<Text fontSize="$sm" color={muted}>
{t('mobilePhotos.count', '{{count}} photos', { count: totalCount })}
</Text>
<div
style={{
@@ -206,15 +227,15 @@ export default function MobileEventPhotosPage() {
>
{photos.map((photo) => (
<Pressable key={photo.id} onPress={() => setLightbox(photo)}>
<YStack borderRadius={10} overflow="hidden" borderWidth={1} borderColor="#e5e7eb">
<YStack borderRadius={10} overflow="hidden" borderWidth={1} borderColor={border}>
<img
src={photo.thumbnail_url ?? photo.url ?? undefined}
alt={photo.caption ?? 'Photo'}
style={{ width: '100%', height: 110, objectFit: 'cover' }}
/>
<XStack position="absolute" top={6} left={6} space="$1">
{photo.is_featured ? <PillBadge tone="warning">{t('events.photos.featured', 'Featured')}</PillBadge> : null}
{photo.status === 'hidden' ? <PillBadge tone="muted">{t('events.photos.hidden', 'Hidden')}</PillBadge> : null}
{photo.is_featured ? <PillBadge tone="warning">{t('photos.filters.featured', 'Featured')}</PillBadge> : null}
{photo.status === 'hidden' ? <PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge> : null}
</XStack>
</YStack>
</Pressable>
@@ -233,7 +254,7 @@ export default function MobileEventPhotosPage() {
width: '100%',
maxWidth: 520,
margin: '0 16px',
background: '#fff',
background: surface,
borderRadius: 20,
overflow: 'hidden',
boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
@@ -242,36 +263,38 @@ export default function MobileEventPhotosPage() {
<img
src={lightbox.url ?? lightbox.thumbnail_url ?? undefined}
alt={lightbox.caption ?? 'Photo'}
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: '#0f172a' }}
style={{ width: '100%', maxHeight: '60vh', objectFit: 'contain', background: backdrop }}
/>
<YStack padding="$3" space="$2">
<XStack space="$2" alignItems="center">
<PillBadge tone="muted">{lightbox.uploader_name || t('events.photos.guest', 'Gast')}</PillBadge>
<PillBadge tone="muted">{lightbox.uploader_name || t('events.members.roles.guest', 'Guest')}</PillBadge>
<PillBadge tone="muted"> {lightbox.likes_count ?? 0}</PillBadge>
</XStack>
<XStack space="$2">
<XStack space="$2" flexWrap="wrap">
<CTAButton
label={
busyId === lightbox.id
? t('common.processing', '...')
: lightbox.is_featured
? t('events.photos.unfeature', 'Unfeature')
: t('events.photos.feature', 'Feature')
? t('photos.actions.unfeature', 'Remove highlight')
: t('photos.actions.feature', 'Set highlight')
}
onPress={() => toggleFeature(lightbox)}
style={{ flex: 1, minWidth: 140 }}
/>
<CTAButton
label={
busyId === lightbox.id
? t('common.processing', '...')
: lightbox.status === 'hidden'
? t('events.photos.show', 'Show')
: t('events.photos.hide', 'Hide')
? t('photos.actions.show', 'Show')
: t('photos.actions.hide', 'Hide')
}
onPress={() => toggleVisibility(lightbox)}
style={{ flex: 1, minWidth: 140 }}
/>
</XStack>
<CTAButton label={t('common.close', 'Close')} onPress={() => setLightbox(null)} />
<CTAButton label={t('common.close', 'Close')} tone="ghost" onPress={() => setLightbox(null)} />
</YStack>
</div>
</div>
@@ -280,10 +303,10 @@ export default function MobileEventPhotosPage() {
<MobileSheet
open={showFilters}
onClose={() => setShowFilters(false)}
title={t('events.photos.filters', 'Filter')}
title={t('mobilePhotos.filtersTitle', 'Filter')}
footer={
<CTAButton
label={t('events.photos.applyFilters', 'Apply filters')}
label={t('mobilePhotos.applyFilters', 'Apply filters')}
onPress={() => {
setPage(1);
setShowFilters(false);
@@ -293,13 +316,13 @@ export default function MobileEventPhotosPage() {
}
>
<YStack space="$2">
<Field label={t('events.photos.uploader', 'Uploader')}>
<Field label={t('mobilePhotos.uploader', 'Uploader')} color={text}>
<input
type="text"
value={uploaderFilter}
onChange={(e) => setUploaderFilter(e.target.value)}
placeholder={t('events.photos.uploaderPlaceholder', 'Name or email')}
style={inputStyle}
placeholder={t('mobilePhotos.uploaderPlaceholder', 'Name or email')}
style={baseInputStyle}
/>
</Field>
<XStack space="$2" alignItems="center">
@@ -309,8 +332,8 @@ export default function MobileEventPhotosPage() {
checked={onlyFeatured}
onChange={(e) => setOnlyFeatured(e.target.checked)}
/>
<Text fontSize="$sm" color="#111827">
{t('events.photos.onlyFeatured', 'Only featured')}
<Text fontSize="$sm" color={text}>
{t('mobilePhotos.onlyFeatured', 'Only featured')}
</Text>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -319,8 +342,8 @@ export default function MobileEventPhotosPage() {
checked={onlyHidden}
onChange={(e) => setOnlyHidden(e.target.checked)}
/>
<Text fontSize="$sm" color="#111827">
{t('events.photos.onlyHidden', 'Only hidden')}
<Text fontSize="$sm" color={text}>
{t('mobilePhotos.onlyHidden', 'Only hidden')}
</Text>
</label>
</XStack>
@@ -339,20 +362,10 @@ export default function MobileEventPhotosPage() {
);
}
const inputStyle: React.CSSProperties = {
width: '100%',
height: 38,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: 'white',
};
function Field({ label, children }: { label: string; children: React.ReactNode }) {
function Field({ label, color, children }: { label: string; color: string; children: React.ReactNode }) {
return (
<YStack space="$1">
<Text fontSize="$sm" color="#111827">
<Text fontSize="$sm" color={color}>
{label}
</Text>
{children}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { RefreshCcw, Plus, Folder, Pencil, Trash2, MoreHorizontal } from 'lucide-react';
import { RefreshCcw, Plus, Pencil, Trash2, ChevronDown } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { ListItem } from '@tamagui/list-item';
@@ -10,9 +10,11 @@ import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import {
getEvent,
getEvents,
getEventTasks,
updateTask,
TenantTask,
TenantEvent,
assignTasksToEvent,
getTasks,
getTaskCollections,
@@ -33,19 +35,20 @@ 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';
const inputStyle: React.CSSProperties = {
const inputBaseStyle = {
width: '100%',
height: 40,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: 'white',
};
} as const;
function InlineSeparator() {
return <XStack height={1} backgroundColor="#e5e7eb" opacity={0.7} marginLeft="$3" />;
const theme = useTheme();
return <XStack height={1} opacity={0.7} marginLeft="$3" backgroundColor={theme.borderColor?.val ?? '#e5e7eb'} />;
}
export default function MobileEventTasksPage() {
@@ -54,14 +57,37 @@ 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 inputStyle = React.useMemo<React.CSSProperties>(
() => ({
...inputBaseStyle,
border: `1px solid ${border}`,
background: surface,
color: text,
}),
[border, surface, text],
);
const [tasks, setTasks] = React.useState<TenantTask[]>([]);
const [assignedTasks, setAssignedTasks] = React.useState<TenantTask[]>([]);
const [library, setLibrary] = React.useState<TenantTask[]>([]);
const [collections, setCollections] = React.useState<TenantTaskCollection[]>([]);
const [emotions, setEmotions] = React.useState<TenantEmotion[]>([]);
const [showCollectionSheet, setShowCollectionSheet] = React.useState(false);
const [showTaskSheet, setShowTaskSheet] = React.useState(false);
const [newTask, setNewTask] = React.useState({ id: null as number | null, title: '', description: '', emotion_id: '' as string | '' });
const [newTask, setNewTask] = React.useState({
id: null as number | null,
title: '',
description: '',
emotion_id: '' as string | '',
tenant_id: null as number | null,
});
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busyId, setBusyId] = React.useState<number | null>(null);
@@ -71,23 +97,42 @@ export default function MobileEventTasksPage() {
const [emotionFilter, setEmotionFilter] = React.useState<string>('');
const [expandedLibrary, setExpandedLibrary] = React.useState(false);
const [expandedCollections, setExpandedCollections] = React.useState(false);
const [showActionsSheet, setShowActionsSheet] = React.useState(false);
const [showFabMenu, setShowFabMenu] = React.useState(false);
const [showBulkSheet, setShowBulkSheet] = React.useState(false);
const [bulkLines, setBulkLines] = React.useState('');
const [showEmotionSheet, setShowEmotionSheet] = React.useState(false);
const [editingEmotion, setEditingEmotion] = React.useState<TenantEmotion | null>(null);
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: '#e5e7eb' });
const [emotionForm, setEmotionForm] = React.useState({ name: '', color: String(border) });
const [savingEmotion, setSavingEmotion] = React.useState(false);
const [showEmotionFilterSheet, setShowEmotionFilterSheet] = React.useState(false);
React.useEffect(() => {
if (slugParam && activeEvent?.slug !== slugParam) {
selectEvent(slugParam);
}
}, [slugParam, activeEvent?.slug, selectEvent]);
// Reset filters when switching events to avoid empty lists due to stale filters.
React.useEffect(() => {
setEmotionFilter('');
setSearchTerm('');
}, [slug]);
const load = React.useCallback(async () => {
if (!slug) {
try {
const available = await getEvents({ force: true });
if (available.length) {
const target = available[0];
selectEvent(target.slug ?? null);
navigate(adminPath(`/mobile/events/${target.slug ?? ''}/tasks`));
return;
}
} catch {
// ignore
} finally {
setError(t('events.errors.missingSlug', 'Kein Event-Slug angegeben.'));
setLoading(false);
}
return;
}
setLoading(true);
@@ -95,22 +140,44 @@ export default function MobileEventTasksPage() {
try {
const event = await getEvent(slug);
setEventId(event.id);
const result = await getEventTasks(event.id, 1);
const libraryTasks = await getTasks({ per_page: 50 });
const [result, libraryTasks] = await Promise.all([
getEventTasks(event.id, 1),
getTasks({ per_page: 200 }),
]);
const collectionList = await getTaskCollections({ per_page: 50 });
const emotionList = await getEmotions();
setTasks(result.data);
setLibrary(libraryTasks.data.filter((task) => !result.data.find((t) => t.id === task.id)));
const assignedIds = new Set(result.data.map((t) => t.id));
const eventTypeId = event.event_type_id ?? event.event_type?.id ?? null;
const filteredLibrary = libraryTasks.data.filter((task) => {
if (assignedIds.has(task.id)) return false;
if (eventTypeId && task.event_type_id && task.event_type_id !== eventTypeId) return false;
return true;
});
setAssignedTasks(result.data);
setLibrary(filteredLibrary);
setCollections(collectionList.data ?? []);
setEmotions(emotionList ?? []);
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('events.errors.loadFailed', 'Tasks konnten nicht geladen werden.')));
const message = getApiErrorMessage(err, t('events.errors.loadFailed', 'Tasks konnten nicht geladen werden.'));
setError(message);
toast.error(message);
// If the current slug is invalid, attempt to recover to a valid event to avoid empty lists.
try {
const available = await getEvents({ force: true });
const fallback = available.find((e: TenantEvent) => e.slug !== slug) ?? available[0];
if (fallback?.slug) {
selectEvent(fallback.slug);
navigate(adminPath(`/mobile/events/${fallback.slug}/tasks`));
}
} catch {
// ignore
}
}
} finally {
setLoading(false);
}
}, [slug, t]);
}, [slug, t, navigate, selectEvent]);
React.useEffect(() => {
void load();
@@ -122,7 +189,7 @@ export default function MobileEventTasksPage() {
try {
await assignTasksToEvent(eventId, [taskId]);
const result = await getEventTasks(eventId, 1);
setTasks(result.data);
setAssignedTasks(result.data);
setLibrary((prev) => prev.filter((t) => t.id !== taskId));
toast.success(t('events.tasks.assigned', 'Task hinzugefügt'));
} catch (err) {
@@ -140,7 +207,9 @@ export default function MobileEventTasksPage() {
try {
await importTaskCollection(collectionId, slug);
const result = await getEventTasks(eventId, 1);
setTasks(result.data);
const assignedIds = new Set(result.data.map((t) => t.id));
setAssignedTasks(result.data);
setLibrary((prev) => prev.filter((t) => !assignedIds.has(t.id)));
toast.success(t('events.tasks.imported', 'Aufgabenpaket importiert'));
} catch (err) {
if (!isAuthError(err)) {
@@ -154,11 +223,31 @@ export default function MobileEventTasksPage() {
if (!eventId || !newTask.title.trim()) return;
try {
if (newTask.id) {
await updateTask(newTask.id, {
if (!Number.isFinite(Number(newTask.id))) {
toast.error(t('events.tasks.updateFailed', 'Task konnte nicht gespeichert werden (ID fehlt).'));
return;
}
const isGlobal = !newTask.tenant_id;
// Global tasks must not be edited in place: clone and replace.
if (isGlobal) {
const cloned = await createTask({
title: newTask.title.trim(),
description: newTask.description.trim() || null,
emotion_id: newTask.emotion_id ? Number(newTask.emotion_id) : undefined,
} as any);
await assignTasksToEvent(eventId, [cloned.id]);
await detachTasksFromEvent(eventId, [Number(newTask.id)]);
} else {
// Tenant-owned task: update in place.
await updateTask(Number(newTask.id), {
id: Number(newTask.id),
title: newTask.title.trim(),
description: newTask.description.trim() || null,
emotion_id: newTask.emotion_id ? Number(newTask.emotion_id) : undefined,
} as any);
}
} else {
const created = await createTask({
title: newTask.title.trim(),
@@ -168,9 +257,11 @@ export default function MobileEventTasksPage() {
await assignTasksToEvent(eventId, [created.id]);
}
const result = await getEventTasks(eventId, 1);
setTasks(result.data);
const assignedIds = new Set(result.data.map((t) => t.id));
setAssignedTasks(result.data);
setLibrary((prev) => prev.filter((t) => !assignedIds.has(t.id)));
setShowTaskSheet(false);
setNewTask({ id: null, title: '', description: '', emotion_id: '' });
setNewTask({ id: null, title: '', description: '', emotion_id: '', tenant_id: null });
toast.success(t('events.tasks.created', 'Aufgabe gespeichert'));
} catch (err) {
if (!isAuthError(err)) {
@@ -185,7 +276,7 @@ export default function MobileEventTasksPage() {
setBusyId(taskId);
try {
await detachTasksFromEvent(eventId, [taskId]);
setTasks((prev) => prev.filter((task) => task.id !== taskId));
setAssignedTasks((prev) => prev.filter((task) => task.id !== taskId));
toast.success(t('events.tasks.removed', 'Aufgabe entfernt'));
} catch (err) {
if (!isAuthError(err)) {
@@ -203,11 +294,12 @@ export default function MobileEventTasksPage() {
title: task.title,
description: task.description ?? '',
emotion_id: task.emotion?.id ? String(task.emotion.id) : '',
tenant_id: (task as any).tenant_id ?? null,
});
setShowTaskSheet(true);
};
const filteredTasks = tasks.filter((task) => {
const filteredTasks = assignedTasks.filter((task) => {
const matchText =
!searchTerm ||
task.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -229,7 +321,7 @@ export default function MobileEventTasksPage() {
await assignTasksToEvent(eventId, [created.id]);
}
const result = await getEventTasks(eventId, 1);
setTasks(result.data);
setAssignedTasks(result.data);
setBulkLines('');
setShowBulkSheet(false);
toast.success(t('events.tasks.created', 'Aufgabe gespeichert'));
@@ -253,7 +345,7 @@ export default function MobileEventTasksPage() {
}
setShowEmotionSheet(false);
setEditingEmotion(null);
setEmotionForm({ name: '', color: '#e5e7eb' });
setEmotionForm({ name: '', color: border });
toast.success(t('events.tasks.emotionSaved', 'Emotion gespeichert'));
} catch (err) {
if (!isAuthError(err)) {
@@ -284,17 +376,14 @@ export default function MobileEventTasksPage() {
headerActions={
<XStack space="$2">
<Pressable onPress={() => load()}>
<RefreshCcw size={18} color="#0f172a" />
</Pressable>
<Pressable onPress={() => setShowActionsSheet(true)}>
<MoreHorizontal size={18} color="#0f172a" />
<RefreshCcw size={18} color={text} />
</Pressable>
</XStack>
}
>
{error ? (
<MobileCard>
<Text fontSize={13} fontWeight="600" color="#b91c1c">
<Text fontSize={13} fontWeight="600" color={danger}>
{error}
</Text>
</MobileCard>
@@ -306,12 +395,76 @@ export default function MobileEventTasksPage() {
<MobileCard key={`tsk-${idx}`} height={70} opacity={0.6} />
))}
</YStack>
) : tasks.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<Text fontSize={13} fontWeight="500" color="#4b5563">
{t('events.tasks.empty', 'Noch keine Aufgaben.')}
) : assignedTasks.length === 0 ? (
<YStack space="$2">
<MobileCard space="$2">
<Text fontSize={13} fontWeight="700" color={text}>
{t('events.tasks.empty', 'Noch keine Aufgaben zugewiesen.')}
</Text>
<Text fontSize={12} color={muted}>
{t('events.tasks.emptyHint', 'Lege jetzt Tasks an oder importiere ein Paket.')}
</Text>
</MobileCard>
<YStack borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
<Pressable onPress={() => setShowTaskSheet(true)}>
<ListItem
title={
<XStack alignItems="center" space="$2">
<YStack
width={28}
height={28}
borderRadius={14}
backgroundColor={primary}
alignItems="center"
justifyContent="center"
>
<Plus size={14} color={surface} />
</YStack>
<Text fontSize={12.5} fontWeight="700" color={text}>
{t('events.tasks.addTask', 'Aufgabe hinzufügen')}
</Text>
</XStack>
}
subTitle={
<Text fontSize={11.5} color={muted}>
{t('events.tasks.addTaskHint', 'Erstelle eine neue Aufgabe für dieses Event.')}
</Text>
}
paddingVertical="$2"
paddingHorizontal="$3"
/>
</Pressable>
<InlineSeparator />
<Pressable onPress={() => setShowCollectionSheet(true)}>
<ListItem
title={
<XStack alignItems="center" space="$2">
<YStack
width={28}
height={28}
borderRadius={14}
backgroundColor={primary}
alignItems="center"
justifyContent="center"
>
<Plus size={14} color={surface} />
</YStack>
<Text fontSize={12.5} fontWeight="700" color={text}>
{t('events.tasks.import', 'Aufgabenpaket importieren')}
</Text>
</XStack>
}
subTitle={
<Text fontSize={11.5} color={muted}>
{t('events.tasks.importHint', 'Nutze vordefinierte Pakete für deinen Event-Typ.')}
</Text>
}
paddingVertical="$2"
paddingHorizontal="$3"
/>
</Pressable>
</YStack>
</YStack>
) : (
<YStack space="$2">
<YStack space="$2">
@@ -322,105 +475,102 @@ export default function MobileEventTasksPage() {
placeholder={t('events.tasks.search', 'Search tasks')}
style={{ ...inputStyle, height: 38 }}
/>
<XStack space="$2" flexWrap="wrap">
<Chip
active={!emotionFilter}
label={t('events.tasks.allEmotions', 'All')}
onPress={() => setEmotionFilter('')}
/>
{emotions.map((emotion) => (
<Chip
key={emotion.id}
label={emotion.name}
color={emotion.color ?? '#e5e7eb'}
active={emotionFilter === String(emotion.id)}
onPress={() => setEmotionFilter(String(emotion.id))}
/>
))}
<Pressable onPress={() => setShowEmotionFilterSheet(true)}>
<MobileCard borderColor={border} backgroundColor={surface} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack>
<Text fontSize={12} fontWeight="700" color={text}>
{t('events.tasks.emotionFilter', 'Emotion filter')}
</Text>
<Text fontSize={11} color={muted}>
{emotionFilter
? emotions.find((e) => String(e.id) === emotionFilter)?.name ?? t('events.tasks.customEmotion', 'Custom emotion')
: t('events.tasks.allEmotions', 'All')}
</Text>
</YStack>
<ChevronDown size={16} color={muted} />
</XStack>
</MobileCard>
</Pressable>
</YStack>
<Text fontSize="$sm" color="#4b5563">
<Text fontSize="$sm" color={muted}>
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
</Text>
<YStack borderWidth={1} borderColor="#e5e7eb" borderRadius="$4" overflow="hidden">
<YStack borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
{filteredTasks.map((task, idx) => (
<React.Fragment key={task.id}>
<Pressable onPress={() => startEdit(task)}>
<ListItem
title={
<Text fontSize={12.5} fontWeight="600" color="#111827">
<Text fontSize={12.5} fontWeight="600" color={text}>
{task.title}
</Text>
}
subTitle={
task.description ? (
<Text fontSize={11.5} fontWeight="400" color="#6b7280">
<Text fontSize={11.5} fontWeight="400" color={subtle}>
{task.description}
</Text>
) : null
}
iconAfter={
<XStack space="$2">
<Pressable onPress={() => startEdit(task)}>
<Pencil size={14} color="#007AFF" />
</Pressable>
<XStack space="$2" alignItems="flex-start">
{task.emotion ? (
<Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? text} />
) : null}
<Pressable disabled={busyId === task.id} onPress={() => detachTask(task.id)}>
<Trash2 size={14} color="#ef4444" />
<Trash2 size={14} color={danger} />
</Pressable>
</XStack>
}
paddingVertical="$2"
paddingHorizontal="$3"
>
{task.emotion ? (
<XStack marginTop="$1">
<Tag label={task.emotion.name ?? ''} color={task.emotion.color ?? '#e5e7eb'} />
</XStack>
) : null}
</ListItem>
{idx < tasks.length - 1 ? <InlineSeparator /> : null}
/>
</Pressable>
{idx < assignedTasks.length - 1 ? <InlineSeparator /> : null}
</React.Fragment>
))}
</YStack>
<XStack justifyContent="space-between" alignItems="center" marginTop="$2">
<Text fontSize={12.5} fontWeight="600" color="#111827">
<Text fontSize={12.5} fontWeight="600" color={text}>
{t('events.tasks.library', 'Weitere Aufgaben')}
</Text>
<Pressable onPress={() => setShowCollectionSheet(true)}>
<Text fontSize={12} fontWeight="600" color="#007AFF">
<Text fontSize={12} fontWeight="600" color={primary}>
{t('events.tasks.import', 'Import Pack')}
</Text>
</Pressable>
</XStack>
<Pressable onPress={() => setExpandedLibrary((prev) => !prev)}>
<Text fontSize={12} fontWeight="600" color="#007AFF">
<Text fontSize={12} fontWeight="600" color={primary}>
{expandedLibrary ? t('events.tasks.hideLibrary', 'Hide library') : t('events.tasks.viewAllLibrary', 'View all')}
</Text>
</Pressable>
{library.length === 0 ? (
<Text fontSize={12} fontWeight="500" color="#6b7280">
<Text fontSize={12} fontWeight="500" color={subtle}>
{t('events.tasks.libraryEmpty', 'Keine weiteren Aufgaben verfügbar.')}
</Text>
) : (
<YStack borderWidth={1} borderColor="#e5e7eb" borderRadius="$4" overflow="hidden">
<YStack borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
{(expandedLibrary ? library : library.slice(0, 6)).map((task, idx, arr) => (
<React.Fragment key={`lib-${task.id}`}>
<ListItem
title={
<Text fontSize={12.5} fontWeight="600" color="#111827">
<Text fontSize={12.5} fontWeight="600" color={text}>
{task.title}
</Text>
}
subTitle={
task.description ? (
<Text fontSize={11.5} fontWeight="400" color="#6b7280">
<Text fontSize={11.5} fontWeight="400" color={subtle}>
{task.description}
</Text>
) : null
}
iconAfter={
<Pressable onPress={() => quickAssign(task.id)}>
<Text fontSize={12} fontWeight="600" color="#007AFF">
<Text fontSize={12} fontWeight="600" color={primary}>
{assigningId === task.id ? t('common.processing', '...') : t('events.tasks.add', 'Add')}
</Text>
</Pressable>
@@ -443,35 +593,37 @@ export default function MobileEventTasksPage() {
footer={null}
>
<YStack space="$2">
{collections.length > 6 ? (
<Pressable onPress={() => setExpandedCollections((prev) => !prev)}>
<Text fontSize={12} fontWeight="600" color="#007AFF">
<Text fontSize={12} fontWeight="600" color={primary}>
{expandedCollections ? t('events.tasks.hideCollections', 'Hide collections') : t('events.tasks.showCollections', 'Show all')}
</Text>
</Pressable>
) : null}
{collections.length === 0 ? (
<Text fontSize={13} fontWeight="500" color="#4b5563">
<Text fontSize={13} fontWeight="500" color={muted}>
{t('events.tasks.collectionsEmpty', 'Keine Pakete vorhanden.')}
</Text>
) : (
<YStack borderWidth={1} borderColor="#e5e7eb" borderRadius="$4" overflow="hidden">
<YStack borderWidth={1} borderColor={border} borderRadius="$4" overflow="hidden">
{(expandedCollections ? collections : collections.slice(0, 6)).map((collection, idx, arr) => (
<React.Fragment key={collection.id}>
<ListItem
title={
<Text fontSize={12.5} fontWeight="600" color="#111827">
<Text fontSize={12.5} fontWeight="600" color={text}>
{collection.name}
</Text>
}
subTitle={
collection.description ? (
<Text fontSize={11.5} fontWeight="400" color="#6b7280">
<Text fontSize={11.5} fontWeight="400" color={subtle}>
{collection.description}
</Text>
) : null
}
iconAfter={
<Pressable onPress={() => importCollection(collection.id)}>
<Text fontSize={12} fontWeight="600" color="#007AFF">
<Text fontSize={12} fontWeight="600" color={primary}>
{t('events.tasks.import', 'Import')}
</Text>
</Pressable>
@@ -496,7 +648,7 @@ export default function MobileEventTasksPage() {
}
>
<YStack space="$2">
<Field label={t('events.tasks.title', 'Titel')}>
<Field label={t('events.tasks.titleLabel', 'Titel')} color={text}>
<input
type="text"
value={newTask.title}
@@ -505,7 +657,7 @@ export default function MobileEventTasksPage() {
style={inputStyle}
/>
</Field>
<Field label={t('events.tasks.description', 'Beschreibung')}>
<Field label={t('events.tasks.description', 'Beschreibung')} color={text}>
<textarea
value={newTask.description}
onChange={(e) => setNewTask((prev) => ({ ...prev, description: e.target.value }))}
@@ -513,7 +665,7 @@ export default function MobileEventTasksPage() {
style={{ ...inputStyle, minHeight: 80 }}
/>
</Field>
<Field label={t('events.tasks.emotion', 'Emotion')}>
<Field label={t('events.tasks.emotion', 'Emotion')} color={text}>
<select
value={newTask.emotion_id}
onChange={(e) => setNewTask((prev) => ({ ...prev, emotion_id: e.target.value }))}
@@ -530,47 +682,6 @@ export default function MobileEventTasksPage() {
</YStack>
</MobileSheet>
<MobileSheet
open={showActionsSheet}
onClose={() => setShowActionsSheet(false)}
title={t('events.tasks.moreActions', 'Mehr Aktionen')}
footer={null}
>
<YStack space="$2">
<ListItem
title={
<Text fontSize={12.5} fontWeight="600" color="#111827">
{t('events.tasks.bulkAdd', 'Bulk add')}
</Text>
}
onPress={() => {
setShowActionsSheet(false);
setShowBulkSheet(true);
}}
paddingVertical="$2"
paddingHorizontal="$3"
/>
<ListItem
title={
<Text fontSize={12.5} fontWeight="600" color="#111827">
{t('events.tasks.manageEmotions', 'Manage emotions')}
</Text>
}
subTitle={
<Text fontSize={11.5} fontWeight="400" color="#6b7280">
{t('events.tasks.manageEmotionsHint', 'Filter and keep your taxonomy tidy.')}
</Text>
}
onPress={() => {
setShowActionsSheet(false);
setShowEmotionSheet(true);
}}
paddingVertical="$2"
paddingHorizontal="$3"
/>
</YStack>
</MobileSheet>
<MobileSheet
open={showBulkSheet}
onClose={() => setShowBulkSheet(false)}
@@ -578,7 +689,7 @@ export default function MobileEventTasksPage() {
footer={<CTAButton label={t('events.tasks.saveTask', 'Aufgabe speichern')} onPress={() => handleBulkAdd()} />}
>
<YStack space="$2">
<Text fontSize={12} color="#4b5563">
<Text fontSize={12} color={muted}>
{t('events.tasks.bulkHint', 'One task per line. These will be created and added to the event.')}
</Text>
<textarea
@@ -595,7 +706,7 @@ export default function MobileEventTasksPage() {
onClose={() => {
setShowEmotionSheet(false);
setEditingEmotion(null);
setEmotionForm({ name: '', color: '#e5e7eb' });
setEmotionForm({ name: '', color: border });
}}
title={t('events.tasks.manageEmotions', 'Manage emotions')}
footer={
@@ -606,7 +717,7 @@ export default function MobileEventTasksPage() {
}
>
<YStack space="$2">
<Field label={t('events.tasks.emotionName', 'Name')}>
<Field label={t('events.tasks.emotionName', 'Name')} color={text}>
<input
type="text"
value={emotionForm.name}
@@ -615,12 +726,12 @@ export default function MobileEventTasksPage() {
style={inputStyle}
/>
</Field>
<Field label={t('events.tasks.emotionColor', 'Farbe')}>
<Field label={t('events.tasks.emotionColor', 'Farbe')} color={text}>
<input
type="color"
value={emotionForm.color}
onChange={(e) => setEmotionForm((prev) => ({ ...prev, color: e.target.value }))}
style={{ width: '100%', height: 44, borderRadius: 10, border: '1px solid #e5e7eb', background: 'white' }}
style={{ width: '100%', height: 44, borderRadius: 10, border: `1px solid ${border}`, background: surface }}
/>
</Field>
<YStack space="$2">
@@ -629,7 +740,7 @@ export default function MobileEventTasksPage() {
key={`emo-${em.id}`}
title={
<XStack alignItems="center" space="$2">
<Tag label={em.name ?? ''} color={em.color ?? '#e5e7eb'} />
<Tag label={em.name ?? ''} color={em.color ?? border} />
</XStack>
}
iconAfter={
@@ -637,13 +748,13 @@ export default function MobileEventTasksPage() {
<Pressable
onPress={() => {
setEditingEmotion(em);
setEmotionForm({ name: em.name ?? '', color: em.color ?? '#e5e7eb' });
setEmotionForm({ name: em.name ?? '', color: em.color ?? border });
}}
>
<Pencil size={14} color="#007AFF" />
<Pencil size={14} color={primary} />
</Pressable>
<Pressable onPress={() => removeEmotion(em.id)}>
<Trash2 size={14} color="#ef4444" />
<Trash2 size={14} color={danger} />
</Pressable>
</XStack>
}
@@ -654,72 +765,45 @@ export default function MobileEventTasksPage() {
</MobileSheet>
<MobileSheet
open={showEmotionSheet}
onClose={() => {
setShowEmotionSheet(false);
setEditingEmotion(null);
setEmotionForm({ name: '', color: '#e5e7eb' });
}}
title={t('events.tasks.manageEmotions', 'Manage emotions')}
open={showEmotionFilterSheet}
onClose={() => setShowEmotionFilterSheet(false)}
title={t('events.tasks.emotionFilter', 'Emotion filter')}
footer={
<CTAButton
label={t('events.tasks.saveEmotion', 'Emotion speichern')}
onPress={() => {
void saveEmotion();
}}
/>
<CTAButton label={t('common.close', 'Close')} onPress={() => setShowEmotionFilterSheet(false)} />
}
>
<RadioGroup
value={emotionFilter}
onValueChange={(val) => {
setEmotionFilter(val);
setShowEmotionFilterSheet(false);
}}
>
<YStack space="$2">
<Field label={t('events.tasks.emotionName', 'Name')}>
<input
type="text"
value={emotionForm.name}
onChange={(e) => setEmotionForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder={t('events.tasks.emotionNamePlaceholder', 'z.B. Joy')}
style={inputStyle}
/>
</Field>
<Field label={t('events.tasks.emotionColor', 'Farbe')}>
<input
type="color"
value={emotionForm.color}
onChange={(e) => setEmotionForm((prev) => ({ ...prev, color: e.target.value }))}
style={{ width: '100%', height: 44, borderRadius: 10, border: '1px solid #e5e7eb', background: 'white' }}
/>
</Field>
<YStack space="$2">
{emotions.map((em) => (
<ListItem
key={`emo-${em.id}`}
title={
<XStack alignItems="center" space="$2">
<Tag label={em.name ?? ''} color={em.color ?? '#e5e7eb'} />
<RadioGroup.Item value="">
<RadioGroup.Indicator />
</RadioGroup.Item>
<Text fontSize={12.5} color={text}>
{t('events.tasks.allEmotions', 'All')}
</Text>
</XStack>
}
iconAfter={
<XStack space="$2">
<Pressable
onPress={() => {
setEditingEmotion(em);
setEmotionForm({ name: em.name ?? '', color: em.color ?? '#e5e7eb' });
}}
>
<Pencil size={14} color="#007AFF" />
</Pressable>
<Pressable onPress={() => removeEmotion(em.id)}>
<Trash2 size={14} color="#ef4444" />
</Pressable>
{emotions.map((emotion) => (
<XStack key={`emo-filter-${emotion.id}`} alignItems="center" space="$2">
<RadioGroup.Item value={String(emotion.id)}>
<RadioGroup.Indicator />
</RadioGroup.Item>
<Text fontSize={12.5} color={emotion.color ?? text}>
{emotion.name ?? ''}
</Text>
</XStack>
}
/>
))}
</YStack>
</YStack>
</RadioGroup>
</MobileSheet>
<Pressable
onPress={() => setShowTaskSheet(true)}
onPress={() => setShowFabMenu(true)}
style={{
position: 'fixed',
right: 20,
@@ -727,46 +811,80 @@ export default function MobileEventTasksPage() {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#007AFF',
backgroundColor: primary,
justifyContent: 'center',
alignItems: 'center',
boxShadow: '0 10px 25px rgba(0,122,255,0.35)',
zIndex: 60,
}}
>
<Plus size={20} color="#ffffff" />
<Plus size={20} color={surface} />
</Pressable>
<MobileSheet
open={showFabMenu}
onClose={() => setShowFabMenu(false)}
title={t('events.tasks.actions', 'Aktionen')}
footer={null}
>
<YStack space="$1">
<ListItem
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{t('events.tasks.addTask', 'Aufgabe hinzufügen')}
</Text>
}
onPress={() => {
setShowFabMenu(false);
setShowTaskSheet(true);
}}
paddingVertical="$2"
paddingHorizontal="$3"
/>
<ListItem
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{t('events.tasks.bulkAdd', 'Bulk add')}
</Text>
}
onPress={() => {
setShowFabMenu(false);
setShowBulkSheet(true);
}}
paddingVertical="$2"
paddingHorizontal="$3"
/>
<ListItem
title={
<Text fontSize={12.5} fontWeight="600" color={text}>
{t('events.tasks.manageEmotions', 'Manage emotions')}
</Text>
}
subTitle={
<Text fontSize={11.5} fontWeight="400" color={subtle}>
{t('events.tasks.manageEmotionsHint', 'Filter and keep your taxonomy tidy.')}
</Text>
}
onPress={() => {
setShowFabMenu(false);
setShowEmotionSheet(true);
}}
paddingVertical="$2"
paddingHorizontal="$3"
/>
</YStack>
</MobileSheet>
</MobileShell>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
function Field({ label, color, children }: { label: string; color: string; children: React.ReactNode }) {
return (
<YStack space="$1">
<Text fontSize={12.5} fontWeight="600" color="#111827">
<Text fontSize={12.5} fontWeight="600" color={color}>
{label}
</Text>
{children}
</YStack>
);
}
function Chip({ label, onPress, active, color }: { label: string; onPress: () => void; active: boolean; color?: string }) {
return (
<Pressable onPress={onPress}>
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical={8}
borderRadius={999}
backgroundColor={active ? '#e0f2fe' : '#f3f4f6'}
borderWidth={1}
borderColor={active ? '#93c5fd' : '#e5e7eb'}
>
<Text fontSize={12} fontWeight="600" color={color ?? (active ? '#0f172a' : '#4b5563')}>
{label}
</Text>
</XStack>
</Pressable>
);
}

View File

@@ -11,6 +11,7 @@ import { getEvents, TenantEvent } from '../api';
import { adminPath } from '../constants';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { useTheme } from '@tamagui/core';
export default function MobileEventsPage() {
const { t } = useTranslation('management');
@@ -19,6 +20,27 @@ export default function MobileEventsPage() {
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [query, setQuery] = React.useState('');
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 baseInputStyle = React.useMemo<React.CSSProperties>(
() => ({
width: '100%',
height: 38,
borderRadius: 10,
border: `1px solid ${border}`,
padding: '0 12px',
fontSize: 13,
background: surface,
color: text,
}),
[border, surface, text],
);
React.useEffect(() => {
(async () => {
@@ -41,13 +63,13 @@ export default function MobileEventsPage() {
onBack={() => navigate(-1)}
headerActions={
<Pressable>
<Search size={18} color="#0f172a" />
<Search size={18} color={text} />
</Pressable>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
@@ -60,16 +82,7 @@ export default function MobileEventsPage() {
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('events.list.search', 'Search events')}
style={{
width: '100%',
height: 38,
borderRadius: 10,
border: '1px solid #e5e7eb',
padding: '0 12px',
fontSize: 13,
background: 'white',
marginBottom: 12,
}}
style={{ ...baseInputStyle, marginBottom: 12 }}
/>
{loading ? (
@@ -83,7 +96,7 @@ export default function MobileEventsPage() {
<Text fontSize="$md" fontWeight="700">
{t('events.list.empty.title', 'Noch kein Event angelegt')}
</Text>
<Text fontSize="$sm" color="#4b5563" textAlign="center">
<Text fontSize="$sm" color={muted} textAlign="center">
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
</Text>
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
@@ -100,6 +113,11 @@ export default function MobileEventsPage() {
<EventRow
key={event.id}
event={event}
text={text}
muted={muted}
subtle={subtle}
border={border}
primary={primary}
onOpen={(slug) => navigate(adminPath(`/mobile/events/${slug}`))}
onEdit={(slug) => navigate(adminPath(`/mobile/events/${slug}/edit`))}
/>
@@ -110,31 +128,49 @@ export default function MobileEventsPage() {
);
}
function EventRow({ event, onOpen, onEdit }: { event: TenantEvent; onOpen: (slug: string) => void; onEdit: (slug: string) => void }) {
function EventRow({
event,
text,
muted,
subtle,
border,
primary,
onOpen,
onEdit,
}: {
event: TenantEvent;
text: string;
muted: string;
subtle: string;
border: string;
primary: string;
onOpen: (slug: string) => void;
onEdit: (slug: string) => void;
}) {
const status = resolveStatus(event);
return (
<MobileCard borderColor="#e2e8f0">
<MobileCard borderColor={border}>
<XStack justifyContent="space-between" alignItems="flex-start" space="$2">
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={text}>
{renderName(event.name)}
</Text>
<XStack alignItems="center" space="$2">
<CalendarDays size={14} color="#6b7280" />
<Text fontSize="$sm" color="#4b5563">
<CalendarDays size={14} color={subtle} />
<Text fontSize="$sm" color={muted}>
{formatDate(event.event_date)}
</Text>
</XStack>
<XStack alignItems="center" space="$2">
<MapPin size={14} color="#6b7280" />
<Text fontSize="$sm" color="#4b5563">
<MapPin size={14} color={subtle} />
<Text fontSize="$sm" color={muted}>
{resolveLocation(event)}
</Text>
</XStack>
<PillBadge tone={status.tone}>{status.label}</PillBadge>
</YStack>
<Pressable onPress={() => onEdit(event.slug)}>
<Text fontSize="$xl" color="#9ca3af">
<Text fontSize="$xl" color={muted}>
˅
</Text>
</Pressable>
@@ -142,8 +178,8 @@ function EventRow({ event, onOpen, onEdit }: { event: TenantEvent; onOpen: (slug
<Pressable onPress={() => onOpen(event.slug)} style={{ marginTop: 8 }}>
<XStack alignItems="center" justifyContent="flex-start" space="$2">
<Plus size={16} color="#007AFF" />
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
<Plus size={16} color={primary} />
<Text fontSize="$sm" color={primary} fontWeight="700">
Open event
</Text>
</XStack>

View File

@@ -13,8 +13,9 @@ 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';
type AlertItem = {
type NotificationItem = {
id: string;
title: string;
body: string;
@@ -22,12 +23,12 @@ type AlertItem = {
tone: 'info' | 'warning';
};
async function loadNotifications(slug?: string): Promise<AlertItem[]> {
async function loadNotifications(slug?: string): Promise<NotificationItem[]> {
try {
const result = slug ? await listGuestNotifications(slug) : [];
return (result ?? []).map((item: GuestNotificationSummary) => ({
id: String(item.id),
title: item.title || 'Alert',
title: item.title || 'Notification',
body: item.body ?? '',
time: item.created_at ?? '',
tone: item.type === 'support_tip' ? 'warning' : 'info',
@@ -37,26 +38,36 @@ async function loadNotifications(slug?: string): Promise<AlertItem[]> {
}
}
export default function MobileAlertsPage() {
export default function MobileNotificationsPage() {
const navigate = useNavigate();
const { t } = useTranslation('management');
const search = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '');
const slug = search.get('event') ?? undefined;
const [alerts, setAlerts] = React.useState<AlertItem[]>([]);
const [notifications, setNotifications] = React.useState<NotificationItem[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [showEventPicker, setShowEventPicker] = React.useState(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 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 reload = React.useCallback(async () => {
setLoading(true);
try {
const data = await loadNotifications(slug ?? undefined);
setAlerts(data);
setNotifications(data);
setError(null);
} catch (err) {
if (!isAuthError(err)) {
const message = getApiErrorMessage(err, t('events.errors.loadFailed', 'Alerts konnten nicht geladen werden.'));
const message = getApiErrorMessage(err, t('events.errors.loadFailed', 'Benachrichtigungen konnten nicht geladen werden.'));
setError(message);
toast.error(message);
}
@@ -83,17 +94,17 @@ export default function MobileAlertsPage() {
return (
<MobileShell
activeTab="home"
title={t('alerts.title', 'Alerts')}
title={t('mobileNotifications.title', 'Notifications')}
onBack={() => navigate(-1)}
headerActions={
<Pressable onPress={() => reload()}>
<RefreshCcw size={18} color="#0f172a" />
<RefreshCcw size={18} color={text} />
</Pressable>
}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
<Text fontWeight="700" color={errorText}>
{error}
</Text>
</MobileCard>
@@ -105,23 +116,23 @@ export default function MobileAlertsPage() {
<MobileCard key={`al-${idx}`} height={70} opacity={0.6} />
))}
</YStack>
) : alerts.length === 0 ? (
) : notifications.length === 0 ? (
<MobileCard alignItems="center" justifyContent="center" space="$2">
<Bell size={24} color="#9ca3af" />
<Text fontSize="$sm" color="#4b5563">
{t('alerts.empty', 'Keine Alerts vorhanden.')}
<Bell size={24} color={String(theme.gray9?.val ?? '#9ca3af')} />
<Text fontSize="$sm" color={muted}>
{t('mobileNotifications.empty', 'Keine Benachrichtigungen vorhanden.')}
</Text>
</MobileCard>
) : (
<YStack space="$2">
{events.length ? (
<Pressable onPress={() => setShowEventPicker(true)}>
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
{t('alerts.filterByEvent', 'Filter by event')}
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('mobileNotifications.filterByEvent', 'Nach Event filtern')}
</Text>
</Pressable>
) : null}
{alerts.map((item) => (
{notifications.map((item) => (
<MobileCard key={item.id} space="$2">
<XStack alignItems="center" space="$2">
<XStack
@@ -130,15 +141,15 @@ export default function MobileAlertsPage() {
borderRadius={12}
alignItems="center"
justifyContent="center"
backgroundColor={item.tone === 'warning' ? '#fef3c7' : '#e0f2fe'}
backgroundColor={item.tone === 'warning' ? warningBg : infoBg}
>
<Bell size={18} color={item.tone === 'warning' ? '#92400e' : '#2563eb'} />
<Bell size={18} color={item.tone === 'warning' ? warningIcon : infoIcon} />
</XStack>
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={text}>
{item.title}
</Text>
<Text fontSize="$xs" color="#4b5563">
<Text fontSize="$xs" color={muted}>
{item.body}
</Text>
</YStack>
@@ -152,12 +163,12 @@ export default function MobileAlertsPage() {
<MobileSheet
open={showEventPicker}
onClose={() => setShowEventPicker(false)}
title={t('alerts.filterByEvent', 'Filter by event')}
title={t('mobileNotifications.filterByEvent', 'Nach Event filtern')}
footer={null}
>
<YStack space="$2">
{events.length === 0 ? (
<Text fontSize="$sm" color="#4b5563">
<Text fontSize="$sm" color={muted}>
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
</Text>
) : (
@@ -167,16 +178,16 @@ export default function MobileAlertsPage() {
onPress={() => {
setShowEventPicker(false);
if (ev.slug) {
navigate(`/admin/mobile/alerts?event=${ev.slug}`);
navigate(`/admin/mobile/notifications?event=${ev.slug}`);
}
}}
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack>
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={text}>
{ev.name}
</Text>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{ev.slug}
</Text>
</YStack>

View File

@@ -5,22 +5,30 @@ 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 { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton } from './components/Primitives';
import { useAuth } from '../auth/context';
import { fetchTenantProfile } from '../api';
import { adminPath } from '../constants';
import i18n from '../i18n';
import { useAppearance } from '@/hooks/use-appearance';
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 [name, setName] = React.useState(user?.name ?? 'Guest');
const [name, setName] = React.useState(user?.name ?? t('events.members.roles.guest', 'Guest'));
const [email, setEmail] = React.useState(user?.email ?? '');
const [role, setRole] = React.useState<string>(user?.role ?? '');
const [theme, setTheme] = React.useState<'light' | 'dark'>('light');
const [language, setLanguage] = React.useState<string>(i18n.language || 'de');
React.useEffect(() => {
@@ -39,7 +47,7 @@ export default function MobileProfilePage() {
return (
<MobileShell
activeTab="profile"
title={t('profile.title', 'Profile')}
title={t('mobileProfile.title', 'Profile')}
onBack={() => navigate(-1)}
>
<MobileCard space="$3" alignItems="center">
@@ -49,38 +57,38 @@ export default function MobileProfilePage() {
borderRadius={20}
alignItems="center"
justifyContent="center"
backgroundColor="#e0f2fe"
backgroundColor={avatarBg}
>
<User size={28} color="#2563eb" />
<User size={28} color={primary} />
</XStack>
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={textColor}>
{name}
</Text>
<Text fontSize="$sm" color="#4b5563">
<Text fontSize="$sm" color={mutedText}>
{email}
</Text>
{role ? (
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={mutedText}>
{role}
</Text>
) : null}
</MobileCard>
<MobileCard space="$3">
<Text fontSize="$md" fontWeight="800" color="#111827">
{t('profile.settings', 'Settings')}
<Text fontSize="$md" fontWeight="800" color={textColor}>
{t('mobileProfile.settings', 'Settings')}
</Text>
<Pressable onPress={() => navigate(adminPath('/mobile/settings'))}>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor="#e5e7eb">
<Text fontSize="$sm" color="#111827">
{t('profile.account', 'Account & Security')}
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor={borderColor}>
<Text fontSize="$sm" color={textColor}>
{t('mobileProfile.account', 'Account & security')}
</Text>
<Settings size={18} color="#9ca3af" />
</XStack>
</Pressable>
<Pressable onPress={() => navigate(adminPath('/mobile/billing#packages'))}>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor="#e5e7eb">
<Text fontSize="$sm" color="#111827">
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor={borderColor}>
<Text fontSize="$sm" color={textColor}>
{t('billing.sections.packages.title', 'Packages & Billing')}
</Text>
<Settings size={18} color="#9ca3af" />
@@ -88,17 +96,17 @@ export default function MobileProfilePage() {
</Pressable>
<Pressable onPress={() => navigate(adminPath('/mobile/billing#invoices'))}>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<Text fontSize="$sm" color="#111827">
<Text fontSize="$sm" color={textColor}>
{t('billing.sections.invoices.title', 'Invoices & Payments')}
</Text>
<Settings size={18} color="#9ca3af" />
</XStack>
</Pressable>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor="#e5e7eb">
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2" borderBottomWidth={1} borderColor={borderColor}>
<XStack space="$2" alignItems="center">
<Globe size={16} color="#6b7280" />
<Text fontSize="$sm" color="#111827">
{t('profile.language', 'Language')}
<Text fontSize="$sm" color={textColor}>
{t('mobileProfile.language', 'Language')}
</Text>
</XStack>
<select
@@ -110,30 +118,31 @@ export default function MobileProfilePage() {
}}
style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: '6px 10px', background: 'white', fontSize: 13 }}
>
<option value="de">Deutsch</option>
<option value="en">English</option>
<option value="de">{t('mobileProfile.languageDe', 'Deutsch')}</option>
<option value="en">{t('mobileProfile.languageEn', 'English')}</option>
</select>
</XStack>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<XStack space="$2" alignItems="center">
<Moon size={16} color="#6b7280" />
<Text fontSize="$sm" color="#111827">
{t('profile.theme', 'Theme')}
<Text fontSize="$sm" color={textColor}>
{t('mobileProfile.theme', 'Theme')}
</Text>
</XStack>
<select
value={theme}
onChange={(e) => setTheme(e.target.value as 'light' | 'dark')}
value={appearance}
onChange={(e) => updateAppearance(e.target.value as 'light' | 'dark' | 'system')}
style={{ border: '1px solid #e5e7eb', borderRadius: 10, padding: '6px 10px', background: 'white', fontSize: 13 }}
>
<option value="light">{t('profile.themeLight', 'Light')}</option>
<option value="dark">{t('profile.themeDark', 'Dark')}</option>
<option value="light">{t('mobileProfile.themeLight', 'Light')}</option>
<option value="dark">{t('mobileProfile.themeDark', 'Dark')}</option>
<option value="system">{t('mobileProfile.themeSystem', 'System')}</option>
</select>
</XStack>
</MobileCard>
<CTAButton
label={t('profile.logout', 'Log out')}
label={t('mobileProfile.logout', 'Log out')}
onPress={() => {
logout();
navigate(adminPath('/logout'));

View File

@@ -18,17 +18,6 @@ import { adminPath } from '../constants';
type PreferenceKey = keyof NotificationPreferences;
const PREFERENCE_LABELS: Record<PreferenceKey, string> = {
task_updates: 'Task updates',
photo_limits: 'Photo limits',
photo_thresholds: 'Photo thresholds',
guest_limits: 'Guest limits',
guest_thresholds: 'Guest thresholds',
purchase_limits: 'Purchase limits',
billing: 'Billing & invoices',
alerts: 'Alerts',
};
export default function MobileSettingsPage() {
const { t } = useTranslation('management');
const navigate = useNavigate();
@@ -79,7 +68,7 @@ export default function MobileSettingsPage() {
};
return (
<MobileShell activeTab="profile" title={t('settings.title', 'Einstellungen')} onBack={() => navigate(-1)}>
<MobileShell activeTab="profile" title={t('mobileSettings.title', 'Settings')} onBack={() => navigate(-1)}>
{error ? (
<MobileCard>
<Text fontWeight="700" color="#b91c1c">
@@ -92,14 +81,14 @@ export default function MobileSettingsPage() {
<XStack alignItems="center" space="$2">
<Shield size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('settings.session.title', 'Account')}
{t('mobileSettings.accountTitle', 'Account')}
</Text>
</XStack>
<Text fontSize="$sm" color="#4b5563">
{user?.name ?? user?.email ?? t('settings.session.unknown', 'Benutzer')}
</Text>
{user?.tenant_id ? (
<PillBadge tone="muted">Tenant #{user.tenant_id}</PillBadge>
<PillBadge tone="muted">{t('mobileSettings.tenantBadge', 'Tenant #{{id}}', { id: user.tenant_id })}</PillBadge>
) : null}
<XStack space="$2">
<CTAButton label={t('settings.profile.actions.openProfile', 'Profil bearbeiten')} onPress={() => navigate(adminPath('/mobile/profile'))} />
@@ -111,21 +100,21 @@ export default function MobileSettingsPage() {
<XStack alignItems="center" space="$2">
<Bell size={18} color="#0f172a" />
<Text fontSize="$md" fontWeight="800" color="#0f172a">
{t('settings.notifications.title', 'Benachrichtigungen')}
{t('mobileSettings.notificationsTitle', 'Notifications')}
</Text>
</XStack>
{loading ? (
<Text fontSize="$sm" color="#6b7280">
{t('settings.notifications.loading', 'Lade Einstellungen ...')}
{t('mobileSettings.notificationsLoading', 'Loading settings ...')}
</Text>
) : (
<YStack space="$2">
{Object.keys(PREFERENCE_LABELS).map((key) => {
{(['task_updates','photo_limits','photo_thresholds','guest_limits','guest_thresholds','purchase_limits','billing','alerts'] as PreferenceKey[]).map((key) => {
const prefKey = key as PreferenceKey;
return (
<XStack key={prefKey} alignItems="center" justifyContent="space-between" borderBottomWidth={1} borderColor="#e5e7eb" paddingBottom="$2" paddingTop="$1.5">
<Text fontSize="$sm" color="#0f172a">
{PREFERENCE_LABELS[prefKey]}
{t(`mobileSettings.pref.${prefKey}`, prefKey)}
</Text>
<input
type="checkbox"

View File

@@ -9,11 +9,17 @@ 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';
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');
if (activeEvent?.slug) {
return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/tasks`)} replace />;
@@ -23,10 +29,10 @@ export default function MobileTasksTabPage() {
return (
<MobileShell activeTab="tasks" title={t('events.tasks.title', 'Tasks')}>
<MobileCard alignItems="flex-start" space="$3">
<Text fontSize="$lg" fontWeight="800" color="#111827">
<Text fontSize="$lg" fontWeight="800" color={text}>
{t('events.tasks.emptyTitle', 'Create an event first')}
</Text>
<Text fontSize="$sm" color="#4b5563">
<Text fontSize="$sm" color={muted}>
{t('events.tasks.emptyBody', 'Start an event to add tasks, challenges, and checklists.')}
</Text>
<CTAButton
@@ -43,7 +49,7 @@ export default function MobileTasksTabPage() {
return (
<MobileShell activeTab="tasks" title={t('events.tasks.title', 'Tasks')}>
<YStack space="$2">
<Text fontSize="$sm" color="#111827" fontWeight="700">
<Text fontSize="$sm" color={text} fontWeight="700">
{t('events.tasks.pickEvent', 'Pick an event to manage tasks')}
</Text>
{events.map((event) => (
@@ -56,17 +62,17 @@ export default function MobileTasksTabPage() {
}
}}
>
<MobileCard borderColor="#e5e7eb" space="$2">
<MobileCard borderColor={border} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={text}>
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={muted}>
{formatEventDate(event.event_date, locale) ?? t('events.status.draft', 'Draft')}
</Text>
</YStack>
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('events.actions.open', 'Open')}
</Text>
</XStack>

View File

@@ -9,11 +9,17 @@ 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';
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');
if (activeEvent?.slug) {
return <Navigate to={adminPath(`/mobile/events/${activeEvent.slug}/photos`)} replace />;
@@ -21,16 +27,16 @@ export default function MobileUploadsTabPage() {
if (!hasEvents) {
return (
<MobileShell activeTab="uploads" title={t('events.photos.title', 'Uploads')}>
<MobileShell activeTab="uploads" title={t('mobileUploads.title', 'Uploads')}>
<MobileCard alignItems="flex-start" space="$3">
<Text fontSize="$lg" fontWeight="800" color="#111827">
{t('events.photos.emptyTitle', 'Create an event first')}
<Text fontSize="$lg" fontWeight="800" color={text}>
{t('mobileUploads.emptyTitle', 'Create an event first')}
</Text>
<Text fontSize="$sm" color="#4b5563">
{t('events.photos.emptyBody', 'Add your first event to review uploads and manage QR sharing.')}
<Text fontSize="$sm" color={muted}>
{t('mobileUploads.emptyBody', 'Add your first event to review uploads and manage QR sharing.')}
</Text>
<CTAButton
label={t('events.actions.create', 'Create Event')}
label={t('mobileDashboard.ctaCreate', 'Create event')}
onPress={() => navigate(adminPath('/mobile/events/new'))}
/>
</MobileCard>
@@ -41,10 +47,10 @@ export default function MobileUploadsTabPage() {
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
return (
<MobileShell activeTab="uploads" title={t('events.photos.title', 'Uploads')}>
<MobileShell activeTab="uploads" title={t('mobileUploads.title', 'Uploads')}>
<YStack space="$2">
<Text fontSize="$sm" color="#111827" fontWeight="700">
{t('events.photos.pickEvent', 'Pick an event to manage uploads')}
<Text fontSize="$sm" color={text} fontWeight="700">
{t('mobileUploads.pickEvent', 'Pick an event to manage uploads')}
</Text>
{events.map((event) => (
<Pressable
@@ -56,18 +62,18 @@ export default function MobileUploadsTabPage() {
}
}}
>
<MobileCard borderColor="#e5e7eb" space="$2">
<MobileCard borderColor={border} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color="#111827">
<Text fontSize="$md" fontWeight="800" color={text}>
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color="#6b7280">
{formatEventDate(event.event_date, locale) ?? t('events.status.draft', 'Draft')}
<Text fontSize="$xs" color={muted}>
{formatEventDate(event.event_date, locale) ?? t('mobileDashboard.status.draft', 'Draft')}
</Text>
</YStack>
<Text fontSize="$sm" color="#007AFF" fontWeight="700">
{t('events.actions.open', 'Open')}
<Text fontSize="$sm" color={primary} fontWeight="700">
{t('mobileUploads.open', 'Open')}
</Text>
</XStack>
</MobileCard>

View File

@@ -6,6 +6,8 @@ import { Home, CheckSquare, Image as ImageIcon, User } from 'lucide-react';
import { useTheme } from '@tamagui/core';
import { useTranslation } from 'react-i18next';
const ICON_SIZE = 18;
export type NavKey = 'home' | 'tasks' | 'uploads' | 'profile';
export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) {
@@ -24,9 +26,9 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
bottom={0}
left={0}
right={0}
backgroundColor="white"
backgroundColor={String(theme.surface?.val ?? 'white')}
borderTopWidth={1}
borderColor="#e5e7eb"
borderColor={String(theme.borderColor?.val ?? '#e5e7eb')}
paddingVertical="$2"
paddingHorizontal="$4"
zIndex={50}
@@ -44,15 +46,31 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
return (
<Pressable key={item.key} onPress={() => onNavigate(item.key)}>
<YStack
flexGrow={1}
flexBasis="0%"
alignItems="center"
justifyContent="center"
space="$1"
position="relative"
padding="$2"
minWidth={88}
minHeight={64}
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={12}
backgroundColor={activeState ? '#e8f1ff' : 'transparent'}
gap="$1"
>
<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'} />
</YStack>
<Text
fontSize="$xs"
fontWeight="700"
fontFamily="$body"
color={activeState ? '$primary' : '#6b7280'}
textAlign="center"
flexShrink={1}
>
<IconCmp size={20} color={activeState ? String(theme.primary?.val ?? '#007AFF') : '#94a3b8'} />
<Text fontSize="$xs" color={activeState ? '$primary' : '#6b7280'}>
{item.label}
</Text>
</YStack>

View File

@@ -1,19 +1,21 @@
import React from 'react';
import React, { Suspense } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronDown, ChevronLeft, Bell, QrCode } 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 { useTranslation } from 'react-i18next';
import { useTheme } from '@tamagui/core';
import { useEventContext } from '../../context/EventContext';
import { BottomNav, NavKey } from './BottomNav';
import { useMobileNav } from '../hooks/useMobileNav';
import { adminPath } from '../../constants';
import { MobileSheet } from './Sheet';
import { MobileCard, PillBadge } from './Primitives';
import { useAlertsBadge } from '../hooks/useAlertsBadge';
import { useNotificationsBadge } from '../hooks/useNotificationsBadge';
import { formatEventDate, resolveEventDisplayName } from '../../lib/events';
import { TenantEvent, getEvents } from '../../api';
const DevTenantSwitcher = React.lazy(() => import('../../components/DevTenantSwitcher'));
type MobileShellProps = {
title?: string;
@@ -29,11 +31,18 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const { go } = useMobileNav(activeEvent?.slug);
const navigate = useNavigate();
const { t, i18n } = useTranslation('mobile');
const { count: alertCount } = useAlertsBadge();
const { count: notificationCount } = useNotificationsBadge();
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 [pickerOpen, setPickerOpen] = React.useState(false);
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [loadingEvents, setLoadingEvents] = React.useState(false);
const [attemptedFetch, setAttemptedFetch] = React.useState(false);
const showDevTenantSwitcher = import.meta.env.DEV && import.meta.env.VITE_ENABLE_TENANT_SWITCHER === 'true';
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
const effectiveEvents = events.length ? events : fallbackEvents;
@@ -81,11 +90,11 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const showQr = Boolean(effectiveActive?.slug);
return (
<YStack backgroundColor="#f7f8fb" minHeight="100vh">
<YStack backgroundColor={backgroundColor} minHeight="100vh">
<YStack
backgroundColor="white"
backgroundColor={surfaceColor}
borderBottomWidth={1}
borderColor="#e5e7eb"
borderColor={borderColor}
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom="$3"
@@ -94,44 +103,49 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
shadowRadius={10}
shadowOffset={{ width: 0, height: 4 }}
>
<XStack alignItems="center" justifyContent="space-between" space="$3">
<XStack alignItems="center" space="$2">
<XStack alignItems="center" justifyContent="space-between" minHeight={48} space="$3">
{onBack ? (
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={18} color="#007AFF" />
</XStack>
</Pressable>
) : null}
) : (
<XStack width={18} />
)}
<XStack alignItems="center" space="$2.5" flex={1} justifyContent="flex-end">
<XStack alignItems="center" space="$1" maxWidth="55%">
<Pressable
disabled={!showEventSwitcher}
onPress={() => setPickerOpen(true)}
style={{ alignItems: 'flex-start' }}
style={{ alignItems: 'flex-end' }}
>
<Text fontSize="$lg" fontWeight="800" color="#111827">
<Text fontSize="$lg" fontWeight="800" fontFamily="$display" color={textColor} textAlign="right" numberOfLines={1}>
{eventTitle}
</Text>
{subtitleText ? (
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={mutedText} textAlign="right" numberOfLines={1} fontFamily="$body">
{subtitleText}
</Text>
) : null}
</Pressable>
{showEventSwitcher ? <ChevronDown size={14} color="#111827" /> : null}
{showEventSwitcher ? <ChevronDown size={14} color={textColor} /> : null}
</XStack>
<XStack alignItems="center" space="$2.5">
<Pressable onPress={() => navigate(adminPath('/mobile/alerts'))}>
<XStack alignItems="center" space="$2">
<Pressable onPress={() => navigate(adminPath('/mobile/notifications'))}>
<XStack
width={34}
height={34}
borderRadius={12}
backgroundColor="#f4f5f7"
backgroundColor={surfaceColor}
alignItems="center"
justifyContent="center"
position="relative"
>
<Bell size={16} color="#111827" />
{alertCount > 0 ? (
<Bell size={16} color={textColor} />
{notificationCount > 0 ? (
<YStack
position="absolute"
top={-4}
@@ -145,7 +159,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
justifyContent="center"
>
<Text fontSize={10} color="white" fontWeight="700">
{alertCount > 9 ? '9+' : alertCount}
{notificationCount > 9 ? '9+' : notificationCount}
</Text>
</YStack>
) : null}
@@ -172,12 +186,19 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
{headerActions ?? null}
</XStack>
</XStack>
</XStack>
</YStack>
<YStack flex={1} padding="$4" paddingBottom="$10" space="$3">
{children}
</YStack>
{showDevTenantSwitcher ? (
<Suspense fallback={null}>
<DevTenantSwitcher bottomOffset={96} />
</Suspense>
) : null}
<BottomNav active={activeTab} onNavigate={go} />
<MobileSheet
@@ -190,10 +211,10 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
<YStack space="$2">
{effectiveEvents.length === 0 ? (
<MobileCard alignItems="flex-start" space="$2">
<Text fontSize="$sm" color="#111827" fontWeight="700">
<Text fontSize="$sm" color={textColor} fontWeight="700">
{t('header.noEventsTitle', 'Create your first event')}
</Text>
<Text fontSize="$xs" color="#4b5563">
<Text fontSize="$xs" color={mutedText}>
{t('header.noEventsBody', 'Start an event to access tasks, uploads, QR posters and more.')}
</Text>
<Pressable onPress={() => navigate(adminPath('/mobile/events/new'))}>
@@ -219,10 +240,10 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
>
<XStack alignItems="center" justifyContent="space-between" paddingVertical="$2">
<YStack space="$0.5">
<Text fontSize="$sm" fontWeight="700" color="#111827">
<Text fontSize="$sm" fontWeight="700" color={textColor}>
{resolveEventDisplayName(event)}
</Text>
<Text fontSize="$xs" color="#6b7280">
<Text fontSize="$xs" color={mutedText}>
{formatEventDate(event.event_date, locale) ?? t('header.noDate', 'Date tbd')}
</Text>
</YStack>

View File

@@ -5,12 +5,13 @@ import { Pressable } from '@tamagui/react-native-web-lite';
import { useTheme } from '@tamagui/core';
export function MobileCard({ children, ...rest }: React.ComponentProps<typeof YStack>) {
const theme = useTheme();
return (
<YStack
backgroundColor="white"
backgroundColor={String(theme.surface?.val ?? 'white')}
borderRadius={16}
borderWidth={1}
borderColor="#e5e7eb"
borderColor={String(theme.borderColor?.val ?? '#e5e7eb')}
shadowColor="#0f172a"
shadowOpacity={0.06}
shadowRadius={12}
@@ -31,10 +32,23 @@ export function PillBadge({
tone?: 'success' | 'warning' | 'muted';
children: React.ReactNode;
}) {
const theme = useTheme();
const palette: Record<typeof tone, { bg: string; text: string; border: string }> = {
success: { bg: '#ecfdf3', text: '#047857', border: '#bbf7d0' },
warning: { bg: '#fffbeb', text: '#92400e', border: '#fef3c7' },
muted: { bg: '#f3f4f6', text: '#374151', border: '#e5e7eb' },
success: {
bg: String(theme.backgroundStrong?.val ?? '#ecfdf3'),
text: String(theme.green10?.val ?? '#047857'),
border: String(theme.green6?.val ?? '#bbf7d0'),
},
warning: {
bg: String(theme.yellow3?.val ?? '#fffbeb'),
text: String(theme.yellow11?.val ?? '#92400e'),
border: String(theme.yellow6?.val ?? '#fef3c7'),
},
muted: {
bg: String(theme.gray3?.val ?? '#f3f4f6'),
text: String(theme.gray11?.val ?? '#374151'),
border: String(theme.gray6?.val ?? '#e5e7eb'),
},
};
const colors = palette[tone] ?? palette.muted;
return (
@@ -72,11 +86,11 @@ export function CTAButton({
borderRadius={14}
alignItems="center"
justifyContent="center"
backgroundColor={isPrimary ? String(theme.primary?.val ?? '#007AFF') : 'white'}
backgroundColor={isPrimary ? String(theme.primary?.val ?? '#007AFF') : String(theme.surface?.val ?? 'white')}
borderWidth={isPrimary ? 0 : 1}
borderColor={isPrimary ? 'transparent' : '#e5e7eb'}
borderColor={isPrimary ? 'transparent' : String(theme.borderColor?.val ?? '#e5e7eb')}
>
<Text fontSize="$sm" fontWeight="800" color={isPrimary ? 'white' : '#111827'}>
<Text fontSize="$sm" fontWeight="800" color={isPrimary ? 'white' : String(theme.color?.val ?? '#111827')}>
{label}
</Text>
</XStack>
@@ -93,17 +107,25 @@ export function KpiTile({
label: string;
value: string | number;
}) {
const theme = useTheme();
return (
<MobileCard borderRadius={14} padding="$3" width="32%" minWidth={110} alignItems="flex-start">
<XStack alignItems="center" space="$2">
<XStack width={32} height={32} borderRadius={12} backgroundColor="#e5f0ff" alignItems="center" justifyContent="center">
<IconCmp size={16} color="#2563eb" />
<XStack
width={32}
height={32}
borderRadius={12}
backgroundColor={String(theme.blue3?.val ?? '#e5f0ff')}
alignItems="center"
justifyContent="center"
>
<IconCmp size={16} color={String(theme.primary?.val ?? '#2563eb')} />
</XStack>
<Text fontSize="$xs" color="#111827">
<Text fontSize="$xs" color={String(theme.color?.val ?? '#111827')}>
{label}
</Text>
</XStack>
<Text fontSize="$xl" fontWeight="800" color="#111827">
<Text fontSize="$xl" fontWeight="800" color={String(theme.color?.val ?? '#111827')}>
{value}
</Text>
</MobileCard>
@@ -121,6 +143,8 @@ export function ActionTile({
color: string;
onPress: () => void;
}) {
const theme = useTheme();
const text = String(theme.color12?.val ?? theme.color?.val ?? '#f8fafc');
return (
<Pressable onPress={onPress} style={{ width: '48%', marginBottom: 12 }}>
<YStack
@@ -137,7 +161,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="#111827" textAlign="center">
<Text fontSize="$sm" fontWeight="700" color={text} textAlign="center">
{label}
</Text>
</YStack>

View File

@@ -4,6 +4,7 @@ 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';
type MobileScaffoldProps = {
title: string;
@@ -15,24 +16,30 @@ 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');
return (
<YStack backgroundColor="#f7f8fb" minHeight="100vh">
<YStack backgroundColor={background} minHeight="100vh">
<XStack
alignItems="center"
justifyContent="space-between"
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom="$3"
backgroundColor="white"
backgroundColor={surface}
borderBottomWidth={1}
borderColor="#e5e7eb"
borderColor={border}
>
<XStack alignItems="center" space="$2">
{onBack ? (
<Pressable onPress={onBack}>
<XStack alignItems="center" space="$1.5">
<ChevronLeft size={18} color="#007AFF" />
<Text fontSize="$sm" color="#007AFF" fontWeight="600">
<ChevronLeft size={18} color={String(theme.primary?.val ?? '#007AFF')} />
<Text fontSize="$sm" color={String(theme.primary?.val ?? '#007AFF')} fontWeight="600">
{t('actions.back', 'Back')}
</Text>
</XStack>
@@ -41,7 +48,7 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
<Text />
)}
</XStack>
<Text fontSize="$lg" fontWeight="800" color="#111827">
<Text fontSize="$lg" fontWeight="800" color={textColor}>
{title}
</Text>
<XStack minWidth={40} justifyContent="flex-end">

View File

@@ -3,6 +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';
type SheetProps = {
open: boolean;
@@ -16,15 +17,21 @@ 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)');
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm">
<div className="fixed inset-0 z-50 flex items-end justify-center backdrop-blur-sm" style={{ backgroundColor: `${overlay}66` }}>
<YStack
width="100%"
maxWidth={520}
borderTopLeftRadius={24}
borderTopRightRadius={24}
backgroundColor="white"
backgroundColor={surface}
padding="$4"
paddingBottom="$7"
space="$3"
@@ -38,11 +45,11 @@ 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="#111827">
<Text fontSize="$md" fontWeight="800" color={text}>
{title}
</Text>
<Pressable onPress={onClose}>
<Text fontSize="$md" color="#6b7280">
<Text fontSize="$md" color={muted}>
{t('actions.close', 'Close')}
</Text>
</Pressable>

View File

@@ -1,20 +1,25 @@
import React from 'react';
import { SizableText as Text } from '@tamagui/text';
import { XStack } from '@tamagui/stacks';
import { useTheme } from '@tamagui/core';
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');
export function Tag({ label, color = '#e5e7eb' }: { label: string; color?: string }) {
return (
<XStack
alignItems="center"
paddingHorizontal="$2"
paddingVertical={2}
borderRadius={999}
backgroundColor={`${color}22`}
backgroundColor={`${baseColor}22`}
borderWidth={1}
borderColor={`${color}55`}
borderColor={`${baseColor}55`}
alignSelf="flex-start"
>
<Text fontSize={11} fontWeight="600" color="#111827">
<Text fontSize={11} fontWeight="600" color={textColor}>
{label}
</Text>
</XStack>

View File

@@ -4,23 +4,23 @@ import { useEventContext } from '../../context/EventContext';
import { listGuestNotifications } from '../../api';
/**
* Lightweight badge count for alerts tab.
* Badge count for notifications bell in the mobile shell.
* Fetches guest notifications for the active event and returns count.
*/
export function useAlertsBadge() {
export function useNotificationsBadge() {
const { activeEvent } = useEventContext();
const slug = activeEvent?.slug;
const { data: count = 0 } = useQuery<number>({
queryKey: ['mobile', 'alerts', 'badge', slug],
queryKey: ['mobile', 'notifications', 'badge', slug],
enabled: Boolean(slug),
staleTime: 60_000,
queryFn: async () => {
if (!slug) {
return 0;
}
const alerts = await listGuestNotifications(slug);
return Array.isArray(alerts) ? alerts.length : 0;
const notifications = await listGuestNotifications(slug);
return Array.isArray(notifications) ? notifications.length : 0;
},
});

View File

@@ -31,7 +31,7 @@ const MobileQrPrintPage = React.lazy(() => import('./mobile/QrPrintPage'));
const MobileEventPhotosPage = React.lazy(() => import('./mobile/EventPhotosPage'));
const MobileEventMembersPage = React.lazy(() => import('./mobile/EventMembersPage'));
const MobileEventTasksPage = React.lazy(() => import('./mobile/EventTasksPage'));
const MobileAlertsPage = React.lazy(() => import('./mobile/AlertsPage'));
const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage'));
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage'));
const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage'));
@@ -138,7 +138,7 @@ export const router = createBrowserRouter([
{ path: 'mobile/events/:slug/photos', element: <MobileEventPhotosPage /> },
{ path: 'mobile/events/:slug/members', element: <RequireAdminAccess><MobileEventMembersPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/tasks', element: <MobileEventTasksPage /> },
{ path: 'mobile/alerts', element: <MobileAlertsPage /> },
{ path: 'mobile/notifications', element: <MobileNotificationsPage /> },
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },

View File

@@ -3,7 +3,7 @@ import '../css/app.css';
import { createInertiaApp } from '@inertiajs/react';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createRoot } from 'react-dom/client';
import { initializeTheme } from './hooks/use-appearance';
import { AppearanceProvider, initializeTheme } from './hooks/use-appearance';
import AppLayout from './layouts/app/AppLayout';
import { I18nextProvider } from 'react-i18next';
import i18n from './i18n';
@@ -63,6 +63,7 @@ createInertiaApp({
}
root.render(
<AppearanceProvider>
<ConsentProvider>
<I18nextProvider i18n={i18n}>
<LocaleSync>
@@ -72,6 +73,7 @@ createInertiaApp({
<Toaster position="top-right" toastOptions={{ duration: 4000 }} />
</I18nextProvider>
</ConsentProvider>
</AppearanceProvider>
);
},
progress: {

View File

@@ -1,68 +1,83 @@
import { useEffect, useState } from 'react';
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
export type Appearance = 'light' | 'dark' | 'system';
export function useAppearance(): { appearance: Appearance; updateAppearance: (mode: Appearance) => void } {
const [appearance, setAppearance] = useState<Appearance>('system');
type AppearanceContextValue = {
appearance: Appearance;
resolved: 'light' | 'dark';
updateAppearance: (mode: Appearance) => void;
};
const AppearanceContext = createContext<AppearanceContextValue>({
appearance: 'system',
resolved: 'light',
updateAppearance: () => {},
});
function resolveTheme(mode: Appearance): 'light' | 'dark' {
if (mode === 'dark') return 'dark';
if (mode === 'light') return 'light';
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyDocumentClass(theme: 'light' | 'dark') {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
export function AppearanceProvider({ children }: { children: React.ReactNode }) {
const [appearance, setAppearance] = useState<Appearance>(() => {
const stored = localStorage.getItem('theme') as Appearance | null;
return stored ?? 'system';
});
const [resolved, setResolved] = useState<'light' | 'dark'>(() => resolveTheme(appearance));
useEffect(() => {
const stored = localStorage.getItem('theme') as Appearance | null;
if (stored) {
setAppearance(stored);
} else {
setAppearance('system');
const nextResolved = resolveTheme(appearance);
setResolved(nextResolved);
applyDocumentClass(nextResolved);
if (appearance === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const listener = () => {
const resolvedTheme = mediaQuery.matches ? 'dark' : 'light';
setResolved(resolvedTheme);
applyDocumentClass(resolvedTheme);
};
mediaQuery.addEventListener('change', listener);
return () => mediaQuery.removeEventListener('change', listener);
}
}, []);
return undefined;
}, [appearance]);
const updateAppearance = (mode: Appearance) => {
setAppearance(mode);
localStorage.setItem('theme', mode);
if (mode === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
useEffect(() => {
if (appearance === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
if (mediaQuery.matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
const listener = (e: MediaQueryListEvent) => {
if (e.matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
mediaQuery.addEventListener('change', listener);
return () => mediaQuery.removeEventListener('change', listener);
} else if (appearance === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [appearance]);
const value = useMemo(
() => ({
appearance,
resolved,
updateAppearance,
}),
[appearance, resolved]
);
return { appearance, updateAppearance };
return React.createElement(AppearanceContext.Provider, { value }, children);
}
export function useAppearance(): AppearanceContextValue {
return useContext(AppearanceContext);
}
export function initializeTheme() {
const stored = localStorage.getItem('theme') as Appearance | null;
if (stored) {
if (stored === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} else {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
if (mediaQuery.matches) {
document.documentElement.classList.add('dark');
}
}
const mode = stored ?? 'system';
const resolved = resolveTheme(mode);
applyDocumentClass(resolved);
}

View File

@@ -1,4 +1,4 @@
import { defaultConfig } from '@tamagui/config/v4'
import { defaultConfig } from '@tamagui/config/v4';
import { createTamagui } from '@tamagui/core';
import { shorthands } from '@tamagui/shorthands';
import { tokens as baseTokens, themes as baseThemes } from '@tamagui/themes';
@@ -46,6 +46,34 @@ const themes = {
},
};
const sharedWeights = {
4: '400',
5: '500',
6: '600',
7: '700',
8: '800',
9: '900',
};
const fonts = {
...defaultConfig.fonts,
body: {
...defaultConfig.fonts.body,
family: 'Montserrat',
weight: sharedWeights,
},
heading: {
...defaultConfig.fonts.heading,
family: 'Montserrat',
weight: sharedWeights,
},
display: {
...defaultConfig.fonts.heading,
family: 'Playfair Display',
weight: sharedWeights,
},
};
const config = createTamagui({
...defaultConfig,
animations: createAnimations({
@@ -55,6 +83,8 @@ const config = createTamagui({
}),
tokens,
themes,
fonts,
defaultFont: 'body',
shorthands,
media: {
...defaultConfig.media,