weitere perfektionierung der neuen mobile app
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
256
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"tasks": "Aufgaben",
|
||||
"uploads": "Uploads",
|
||||
"profile": "Profil",
|
||||
"alerts": "Alerts",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"events": "Events"
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"tasks": "Tasks",
|
||||
"uploads": "Uploads",
|
||||
"profile": "Profile",
|
||||
"alerts": "Alerts",
|
||||
"notifications": "Notifications",
|
||||
"events": "Events"
|
||||
},
|
||||
"actions": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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'));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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> },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user