Refine admin PWA layout and tamagui usage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-15 22:24:10 +01:00
parent 11018f273d
commit 292c8f0b26
37 changed files with 51503 additions and 21989 deletions

View File

@@ -337,8 +337,8 @@ Tokens are design system values that can be referenced using the `$` prefix.
### Color Tokens
- `accent`: #FFB6C1
- `accentSoft`: #FFE5EC
- `accent`: #3D5AFE
- `accentSoft`: #E8ECFF
- `blue10Dark`: hsl(209, 100%, 60.6%)
- `blue10Light`: hsl(208, 100%, 47.3%)
- `blue11Dark`: hsl(210, 100%, 66.1%)
@@ -363,8 +363,8 @@ Tokens are design system values that can be referenced using the `$` prefix.
- `blue8Light`: hsl(206, 81.9%, 65.3%)
- `blue9Dark`: hsl(206, 100%, 50.0%)
- `blue9Light`: hsl(206, 100%, 50.0%)
- `border`: #F2E4DA
- `danger`: #E04848
- `border`: #F3D6C9
- `danger`: #EF4444
- `gray10Dark`: hsl(0, 0%, 49.4%)
- `gray10Light`: hsl(0, 0%, 52.3%)
- `gray11Dark`: hsl(0, 0%, 62.8%)
@@ -413,7 +413,7 @@ Tokens are design system values that can be referenced using the `$` prefix.
- `green8Light`: hsl(151, 40.2%, 54.1%)
- `green9Dark`: hsl(151, 55.0%, 41.5%)
- `green9Light`: hsl(151, 55.0%, 41.5%)
- `muted`: #F4ECE8
- `muted`: #FFF6F0
- `orange10Dark`: hsl(24, 100%, 58.5%)
- `orange10Light`: hsl(24, 100%, 46.5%)
- `orange11Dark`: hsl(24, 100%, 62.2%)
@@ -462,7 +462,7 @@ Tokens are design system values that can be referenced using the `$` prefix.
- `pink8Light`: hsl(323, 60.3%, 72.4%)
- `pink9Dark`: hsl(322, 65.0%, 54.5%)
- `pink9Light`: hsl(322, 65.0%, 54.5%)
- `primary`: #FF5A5F
- `primary`: #FF5C5C
- `purple10Dark`: hsl(273, 57.3%, 59.1%)
- `purple10Light`: hsl(272, 46.8%, 50.3%)
- `purple11Dark`: hsl(275, 80.0%, 71.0%)
@@ -511,10 +511,10 @@ Tokens are design system values that can be referenced using the `$` prefix.
- `red8Light`: hsl(359, 69.5%, 74.3%)
- `red9Dark`: hsl(358, 75.0%, 59.0%)
- `red9Light`: hsl(358, 75.0%, 59.0%)
- `success`: #06D6A0
- `success`: #22C55E
- `surface`: #ffffff
- `text`: #1F2937
- `warning`: #F5C542
- `text`: #0B132B
- `warning`: #FBBF24
- `yellow10Dark`: hsl(54, 100%, 68.0%)
- `yellow10Light`: hsl(50, 100%, 48.5%)
- `yellow11Dark`: hsl(48, 100%, 47.0%)

View File

@@ -4160,16 +4160,16 @@ var tokens3 = {
...tokens2,
color: {
...tokens2.color,
primary: "#FF5A5F",
accent: "#FFB6C1",
accentSoft: "#FFE5EC",
success: "#06D6A0",
warning: "#F5C542",
danger: "#E04848",
primary: "#FF5C5C",
accent: "#3D5AFE",
accentSoft: "#E8ECFF",
success: "#22C55E",
warning: "#FBBF24",
danger: "#EF4444",
surface: "#ffffff",
muted: "#F4ECE8",
border: "#F2E4DA",
text: "#1F2937"
muted: "#FFF6F0",
border: "#F3D6C9",
text: "#0B132B"
},
radius: {
...tokens2.radius,
@@ -4188,53 +4188,53 @@ var themes3 = {
...themes2.light,
primary: tokens3.color.primary,
accent: tokens3.color.accent,
background: "#FFF8F5",
backgroundHover: "#FFF1EC",
backgroundPress: "#FFE7E0",
background: "#FFF1E8",
backgroundHover: "#FFE8DD",
backgroundPress: "#FFE1D2",
backgroundStrong: tokens3.color.surface,
backgroundTransparent: "rgba(255, 248, 245, 0)",
backgroundTransparent: "rgba(255, 241, 232, 0)",
color: tokens3.color.text,
colorHover: "#111827",
colorPress: "#0F172A",
colorFocus: "#0F172A",
colorHover: "#091024",
colorPress: "#091024",
colorFocus: "#091024",
borderColor: tokens3.color.border,
borderColorHover: "#EAD5C9",
borderColorPress: "#E0C9BC",
shadowColor: "rgba(31, 41, 55, 0.12)",
shadowColorPress: "rgba(31, 41, 55, 0.16)",
shadowColorFocus: "rgba(31, 41, 55, 0.18)",
borderColorHover: "#EBCABA",
borderColorPress: "#E1BFAE",
shadowColor: "rgba(11, 19, 43, 0.16)",
shadowColorPress: "rgba(11, 19, 43, 0.2)",
shadowColorFocus: "rgba(11, 19, 43, 0.24)",
surface: tokens3.color.surface,
muted: tokens3.color.muted,
blue3: tokens3.color.accentSoft,
blue6: tokens3.color.accent,
blue10: tokens3.color.primary,
blue11: "#C2413B"
blue11: "#1E36F1"
},
dark: {
...themes2.dark,
primary: tokens3.color.primary,
accent: tokens3.color.accent,
background: "#171219",
backgroundHover: "#1F1A23",
backgroundPress: "#26212B",
backgroundStrong: "#1F1A23",
backgroundTransparent: "rgba(23, 18, 25, 0)",
color: "#F8F6F2",
background: "#0B132B",
backgroundHover: "#101A36",
backgroundPress: "#132142",
backgroundStrong: "#101A36",
backgroundTransparent: "rgba(11, 19, 43, 0)",
color: "#F8FAFF",
colorHover: "#FFFFFF",
colorPress: "#FDF8F5",
colorPress: "#F2F6FF",
colorFocus: "#FFFFFF",
borderColor: "#2C2531",
borderColorHover: "#3A3240",
borderColorPress: "#443C4A",
borderColor: "#1F2A4A",
borderColorHover: "#29345A",
borderColorPress: "#313D67",
shadowColor: "rgba(0, 0, 0, 0.55)",
shadowColorPress: "rgba(0, 0, 0, 0.65)",
shadowColorFocus: "rgba(0, 0, 0, 0.6)",
surface: "#1F1A23",
muted: "#241E28",
blue3: "#2B1D23",
blue6: "#5A2D34",
blue10: "#FF7A7F",
blue11: "#FFB3B6"
surface: "#0F1B36",
muted: "#121F3D",
blue3: "#1B2550",
blue6: "#3D5AFE",
blue10: "#FF5C5C",
blue11: "#FF8A8A"
}
};
var sharedWeights = {
@@ -4254,12 +4254,12 @@ var fonts2 = {
},
heading: {
...defaultConfig.fonts.heading,
family: "Manrope",
family: "Archivo Black",
weight: sharedWeights
},
display: {
...defaultConfig.fonts.heading,
family: "Fraunces",
family: "Archivo Black",
weight: sharedWeights
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,490 +1,106 @@
/* Auto-generated by fonts:sync-google */
@font-face {
font-family: 'Roboto';
font-family: 'Manifest Font';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/roboto/Roboto-400-normal.ttf') format('truetype');
src: url('/fonts/google/manifest-font/regular.woff2') format('woff2');
}
@font-face {
font-family: 'Roboto';
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-400-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-500-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: italic;
font-weight: 500;
font-display: swap;
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-500-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-600-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: italic;
font-weight: 600;
font-display: swap;
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-600-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/roboto/Roboto-700-normal.ttf') format('truetype');
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Open Sans';
font-family: 'Plus Jakarta Sans';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/plus-jakarta-sans/PlusJakartaSans-700-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/open-sans/OpenSans-400-normal.ttf') format('truetype');
src: url('/fonts/google/space-grotesk/SpaceGrotesk-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Open Sans';
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/google/space-grotesk/SpaceGrotesk-500-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/google/space-grotesk/SpaceGrotesk-600-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/open-sans/OpenSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans JP';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/noto-sans-jp/NotoSansJp-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans JP';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/noto-sans-jp/NotoSansJp-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/lato/Lato-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/lato/Lato-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/montserrat/Montserrat-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/montserrat/Montserrat-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/inter/Inter-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/inter/Inter-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/poppins/Poppins-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/poppins/Poppins-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Material Icons';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/material-icons/MaterialIcons-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto Condensed';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/roboto-condensed/RobotoCondensed-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto Condensed';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/roboto-condensed/RobotoCondensed-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/roboto-mono/RobotoMono-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/roboto-mono/RobotoMono-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Arimo';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/arimo/Arimo-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Arimo';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/arimo/Arimo-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Oswald';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/oswald/Oswald-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Oswald';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/oswald/Oswald-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/noto-sans/NotoSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/noto-sans/NotoSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/raleway/Raleway-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/raleway/Raleway-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/nunito-sans/NunitoSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/nunito-sans/NunitoSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/nunito/Nunito-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Nunito';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/nunito/Nunito-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/playfair-display/PlayfairDisplay-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/playfair-display/PlayfairDisplay-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Ubuntu';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/ubuntu/Ubuntu-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Ubuntu';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/ubuntu/Ubuntu-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Rubik';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/rubik/Rubik-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Rubik';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/rubik/Rubik-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans KR';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/noto-sans-kr/NotoSansKr-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans KR';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/noto-sans-kr/NotoSansKr-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto Slab';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/roboto-slab/RobotoSlab-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Roboto Slab';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/roboto-slab/RobotoSlab-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/dm-sans/DmSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/dm-sans/DmSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Kanit';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/kanit/Kanit-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Kanit';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/kanit/Kanit-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/merriweather/Merriweather-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Merriweather';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/merriweather/Merriweather-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/work-sans/WorkSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/work-sans/WorkSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/pt-sans/PtSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'PT Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/pt-sans/PtSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/material-symbols-outlined/MaterialSymbolsOutlined-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Material Symbols Outlined';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/material-symbols-outlined/MaterialSymbolsOutlined-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Lora';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/lora/Lora-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Lora';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/lora/Lora-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/quicksand/Quicksand-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Quicksand';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/quicksand/Quicksand-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Mulish';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/mulish/Mulish-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Mulish';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/mulish/Mulish-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans TC';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/noto-sans-tc/NotoSansTc-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Sans TC';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/noto-sans-tc/NotoSansTc-700-normal.ttf') format('truetype');
src: url('/fonts/google/space-grotesk/SpaceGrotesk-700-normal.ttf') format('truetype');
}
@font-face {
@@ -520,337 +136,9 @@
}
@font-face {
font-family: 'Figtree';
font-family: 'Archivo Black';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/figtree/Figtree-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Figtree';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/figtree/Figtree-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Inconsolata';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/inconsolata/Inconsolata-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Inconsolata';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/inconsolata/Inconsolata-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/ibm-plex-sans/IbmPlexSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/ibm-plex-sans/IbmPlexSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Fira Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/fira-sans/FiraSans-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Fira Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/fira-sans/FiraSans-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Barlow';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/barlow/Barlow-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Barlow';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/barlow/Barlow-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/outfit/Outfit-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/outfit/Outfit-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Source Sans 3';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/source-sans-3/SourceSans3-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Source Sans 3';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/source-sans-3/SourceSans3-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Bebas Neue';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/bebas-neue/BebasNeue-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Titillium Web';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/titillium-web/TitilliumWeb-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Titillium Web';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/titillium-web/TitilliumWeb-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Karla';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/karla/Karla-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Karla';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/karla/Karla-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Material Icons Outlined';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/material-icons-outlined/MaterialIconsOutlined-400-normal.otf') format('opentype');
}
@font-face {
font-family: 'PT Serif';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/pt-serif/PtSerif-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'PT Serif';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/pt-serif/PtSerif-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Serif';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/noto-serif/NotoSerif-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Noto Serif';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/noto-serif/NotoSerif-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Jost';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/jost/Jost-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Jost';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/jost/Jost-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Prompt';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/prompt/Prompt-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Prompt';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/prompt/Prompt-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Heebo';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/heebo/Heebo-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Heebo';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/heebo/Heebo-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Saira';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/saira/Saira-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Saira';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/saira/Saira-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/archivo/Archivo-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Archivo';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/archivo/Archivo-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-400-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-400-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-500-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: italic;
font-weight: 500;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-500-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-600-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: italic;
font-weight: 600;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-600-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-700-normal.ttf') format('truetype');
}
@font-face {
font-family: 'Fraunces';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('/fonts/google/fraunces/Fraunces-700-italic.ttf') format('truetype');
src: url('/fonts/google/archivo-black/ArchivoBlack-400-normal.ttf') format('truetype');
}

View File

@@ -1 +1,149 @@
{"fonts":[{"family":"Manifest Font","category":"sans-serif","variants":[{"variant":"regular","weight":400,"style":"normal","url":"\/fonts\/google\/manifest-font\/regular.woff2"}]}]}
{
"generated_at": "2026-01-15T21:06:13+01:00",
"source": "google-webfonts",
"count": 5,
"fonts": [
{
"family": "Manifest Font",
"category": "sans-serif",
"variants": [
{
"variant": "regular",
"weight": 400,
"style": "normal",
"url": "/fonts/google/manifest-font/regular.woff2"
}
]
},
{
"family": "Plus Jakarta Sans",
"slug": "plus-jakarta-sans",
"category": "sans-serif",
"variants": [
{
"variant": "regular",
"weight": 400,
"style": "normal",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-400-normal.ttf"
},
{
"variant": "italic",
"weight": 400,
"style": "italic",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-400-italic.ttf"
},
{
"variant": 500,
"weight": 500,
"style": "normal",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-500-normal.ttf"
},
{
"variant": "500italic",
"weight": 500,
"style": "italic",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-500-italic.ttf"
},
{
"variant": 600,
"weight": 600,
"style": "normal",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-600-normal.ttf"
},
{
"variant": "600italic",
"weight": 600,
"style": "italic",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-600-italic.ttf"
},
{
"variant": 700,
"weight": 700,
"style": "normal",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-700-normal.ttf"
},
{
"variant": "700italic",
"weight": 700,
"style": "italic",
"url": "/fonts/google/plus-jakarta-sans/PlusJakartaSans-700-italic.ttf"
}
]
},
{
"family": "Space Grotesk",
"slug": "space-grotesk",
"category": "sans-serif",
"variants": [
{
"variant": "regular",
"weight": 400,
"style": "normal",
"url": "/fonts/google/space-grotesk/SpaceGrotesk-400-normal.ttf"
},
{
"variant": 500,
"weight": 500,
"style": "normal",
"url": "/fonts/google/space-grotesk/SpaceGrotesk-500-normal.ttf"
},
{
"variant": 600,
"weight": 600,
"style": "normal",
"url": "/fonts/google/space-grotesk/SpaceGrotesk-600-normal.ttf"
},
{
"variant": 700,
"weight": 700,
"style": "normal",
"url": "/fonts/google/space-grotesk/SpaceGrotesk-700-normal.ttf"
}
]
},
{
"family": "Manrope",
"slug": "manrope",
"category": "sans-serif",
"variants": [
{
"variant": "regular",
"weight": 400,
"style": "normal",
"url": "/fonts/google/manrope/Manrope-400-normal.ttf"
},
{
"variant": 500,
"weight": 500,
"style": "normal",
"url": "/fonts/google/manrope/Manrope-500-normal.ttf"
},
{
"variant": 600,
"weight": 600,
"style": "normal",
"url": "/fonts/google/manrope/Manrope-600-normal.ttf"
},
{
"variant": 700,
"weight": 700,
"style": "normal",
"url": "/fonts/google/manrope/Manrope-700-normal.ttf"
}
]
},
{
"family": "Archivo Black",
"slug": "archivo-black",
"category": "sans-serif",
"variants": [
{
"variant": "regular",
"weight": 400,
"style": "normal",
"url": "/fonts/google/archivo-black/ArchivoBlack-400-normal.ttf"
}
]
}
]
}

View File

@@ -85,7 +85,7 @@ function AdminApp() {
</div>
)}
>
<div className="bg-[#FFF8F5] font-[Manrope] text-[14px] font-normal leading-[1.6] text-[#1F2937] dark:bg-[#15121A] dark:text-slate-100">
<div className="bg-[#FFF1E8] font-[Manrope] text-[14px] font-normal leading-[1.6] text-[#0B132B] dark:bg-[#0B132B] dark:text-slate-100">
<RouterProvider router={router} />
</div>
</Suspense>

View File

@@ -122,6 +122,10 @@ export default function MobileBrandingPage() {
setLoading(true);
try {
const data = await getEvent(slug);
if (!data) {
setLoading(false);
return;
}
setEvent(data);
setForm(extractBrandingForm(data.settings ?? {}, BRANDING_FORM_DEFAULTS));
setWatermarkForm(extractWatermark(data));
@@ -153,7 +157,7 @@ export default function MobileBrandingPage() {
let active = true;
getTenantSettings()
.then((payload) => {
if (!active) return;
if (!active || !payload) return;
setTenantBranding(extractBrandingForm(payload.settings ?? {}, BRANDING_FORM_DEFAULTS));
})
.catch(() => undefined)
@@ -170,7 +174,7 @@ export default function MobileBrandingPage() {
const previewForm = form.useDefaultBranding && tenantBranding ? tenantBranding : form;
const previewTitle = event ? renderName(event.name) : t('events.placeholders.untitled', 'Unbenanntes Event');
const previewHeadingFont = previewForm.headingFont || 'Fraunces';
const previewHeadingFont = previewForm.headingFont || 'Archivo Black';
const previewBodyFont = previewForm.bodyFont || 'Manrope';
const previewSurfaceText = getContrastingTextColor(previewForm.surface, '#ffffff', '#0f172a');
const previewScale = FONT_SIZE_SCALE[previewForm.fontSize] ?? 1;

View File

@@ -3,6 +3,9 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Bell, CalendarDays, Camera, CheckCircle2, ChevronDown, Download, Image as ImageIcon, Layout, ListTodo, MapPin, Megaphone, MessageCircle, Pencil, QrCode, Settings, ShieldCheck, Smartphone, Sparkles, TrendingUp, Tv, Users } from 'lucide-react';
import { Card } from '@tamagui/card';
import { Progress } from '@tamagui/progress';
import { XGroup, YGroup } from '@tamagui/group';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
@@ -11,7 +14,7 @@ import { MobileCard, CTAButton, KpiTile, ActionTile, PillBadge, SkeletonCard } f
import { MobileSheet } from './components/Sheet';
import { adminPath, ADMIN_WELCOME_BASE_PATH } from '../constants';
import { useEventContext } from '../context/EventContext';
import { fetchOnboardingStatus, getEventStats, EventStats, TenantEvent, getEvents, getTenantPackagesOverview } from '../api';
import { fetchOnboardingStatus, getEventStats, EventStats, TenantEvent, getEvents, getTenantPackagesOverview, TenantPackageSummary } from '../api';
import { formatEventDate, isBrandingAllowed, resolveEngagementMode, resolveEventDisplayName } from '../lib/events';
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
import { useDevicePermissions } from './hooks/useDevicePermissions';
@@ -405,9 +408,9 @@ export default function MobileDashboardPage() {
if (!effectiveHasEvents) {
return (
<MobileShell activeTab="home" title={t('mobileDashboard.title', 'Dashboard')}>
{showPackageSummaryBanner ? (
{showPackageSummaryBanner && activePackage ? (
<PackageSummaryBanner
packageName={activePackage?.package_name}
activePackage={activePackage}
onOpen={() => setSummaryOpen(true)}
/>
) : null}
@@ -430,9 +433,9 @@ export default function MobileDashboardPage() {
title={t('mobileDashboard.title', 'Dashboard')}
subtitle={t('mobileDashboard.selectEvent', 'Select an event to continue')}
>
{showPackageSummaryBanner ? (
{showPackageSummaryBanner && activePackage ? (
<PackageSummaryBanner
packageName={activePackage?.package_name}
activePackage={activePackage}
onOpen={() => setSummaryOpen(true)}
/>
) : null}
@@ -448,9 +451,9 @@ export default function MobileDashboardPage() {
activeTab="home"
title={t('mobileDashboard.title', 'Dashboard')}
>
{showPackageSummaryBanner ? (
{showPackageSummaryBanner && activePackage ? (
<PackageSummaryBanner
packageName={activePackage?.package_name}
activePackage={activePackage}
onOpen={() => setSummaryOpen(true)}
/>
) : null}
@@ -620,18 +623,46 @@ function SummaryRow({ label, value }: { label: string; value: string }) {
}
function PackageSummaryBanner({
packageName,
activePackage,
onOpen,
}: {
packageName?: string | null;
activePackage: TenantPackageSummary;
onOpen: () => void;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const { textStrong, muted, border, surface, accentSoft, primary, surfaceMuted, shadow } = useAdminTheme();
const text = textStrong;
const packageName = activePackage.package_name ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary');
const remainingEvents = typeof activePackage.remaining_events === 'number' ? activePackage.remaining_events : null;
const hasLimit = remainingEvents !== null;
const totalEvents = hasLimit ? activePackage.used_events + remainingEvents : null;
const showProgress = hasLimit && (totalEvents ?? 0) > 0;
const usageLabel = hasLimit
? t('mobileDashboard.packageSummary.bannerUsage', '{{used}} of {{total}} events used', {
used: activePackage.used_events,
total: totalEvents ?? 0,
})
: t('mobileDashboard.packageSummary.bannerUnlimited', 'Unlimited events');
const remainingLabel = hasLimit
? t('mobileDashboard.packageSummary.bannerRemaining', '{{count}} remaining', { count: remainingEvents })
: t('mobileDashboard.packageSummary.bannerRemainingUnlimited', 'No limit');
const progressMax = totalEvents ?? 0;
const progressValue = Math.min(activePackage.used_events, progressMax);
return (
<MobileCard space="$2" borderColor={border} backgroundColor={surface}>
<Card
className="admin-fade-up"
space="$2"
borderRadius={20}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<XStack alignItems="center" space="$2" flex={1}>
<XStack
@@ -650,7 +681,7 @@ function PackageSummaryBanner({
</Text>
<Text fontSize="$xs" color={muted}>
{t('mobileDashboard.packageSummary.bannerSubtitle', {
name: packageName ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary'),
name: packageName,
defaultValue: '{{name}} is active. Review limits & features.',
})}
</Text>
@@ -663,7 +694,29 @@ function PackageSummaryBanner({
onPress={onOpen}
/>
</XStack>
</MobileCard>
<YStack space="$1.5">
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<Text fontSize="$xs" color={muted}>
{usageLabel}
</Text>
<Text fontSize="$xs" color={muted}>
{remainingLabel}
</Text>
</XStack>
{showProgress ? (
<Progress
value={progressValue}
max={progressMax}
size="$2"
backgroundColor={surfaceMuted}
borderWidth={1}
borderColor={border}
>
<Progress.Indicator backgroundColor={primary} />
</Progress>
) : null}
</YStack>
</Card>
);
}
@@ -1112,7 +1165,7 @@ function EventHeaderCard({
onEdit: () => void;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, accentSoft, primary } = useAdminTheme();
const { textStrong, muted, border, surface, accentSoft, primary, shadow } = useAdminTheme();
if (!event) {
return null;
@@ -1122,39 +1175,52 @@ function EventHeaderCard({
const locationLabel = resolveLocation(event, t);
return (
<MobileCard space="$3" borderColor={border} backgroundColor={surface} position="relative">
<XStack alignItems="center" justifyContent="space-between" space="$2">
{canSwitch ? (
<Pressable onPress={onSwitch} aria-label={t('mobileDashboard.pickEvent', 'Select an event')}>
<XStack alignItems="center" space="$2">
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
<ChevronDown size={16} color={muted} />
</XStack>
</Pressable>
) : (
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
)}
<PillBadge tone={event.status === 'published' ? 'success' : 'warning'}>
{event.status === 'published'
? t('events.status.published', 'Live')
: t('events.status.draft', 'Draft')}
</PillBadge>
</XStack>
<Card
position="relative"
borderRadius={20}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
>
<YStack space="$2.5">
<XStack alignItems="center" justifyContent="space-between" space="$2">
{canSwitch ? (
<Pressable onPress={onSwitch} aria-label={t('mobileDashboard.pickEvent', 'Select an event')}>
<XStack alignItems="center" space="$2">
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
<ChevronDown size={16} color={muted} />
</XStack>
</Pressable>
) : (
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
{resolveEventDisplayName(event)}
</Text>
)}
<PillBadge tone={event.status === 'published' ? 'success' : 'warning'}>
{event.status === 'published'
? t('events.status.published', 'Live')
: t('events.status.draft', 'Draft')}
</PillBadge>
</XStack>
<XStack alignItems="center" space="$2">
<CalendarDays size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{dateLabel}
</Text>
<MapPin size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{locationLabel}
</Text>
</XStack>
<XStack alignItems="center" space="$2">
<CalendarDays size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{dateLabel}
</Text>
<MapPin size={16} color={muted} />
<Text fontSize="$sm" color={muted}>
{locationLabel}
</Text>
</XStack>
</YStack>
<Pressable
aria-label={t('mobileEvents.edit', 'Edit event')}
@@ -1174,7 +1240,7 @@ function EventHeaderCard({
>
<Pencil size={18} color={primary} />
</Pressable>
</MobileCard>
</Card>
);
}
@@ -1188,7 +1254,7 @@ function EventManagementGrid({
onNavigate: (path: string) => void;
}) {
const { t } = useTranslation('management');
const { textStrong } = useAdminTheme();
const { textStrong, border, surface, surfaceMuted, shadow } = useAdminTheme();
const slug = event?.slug ?? null;
const brandingAllowed = isBrandingAllowed(event ?? null);
@@ -1287,25 +1353,67 @@ function EventManagementGrid({
});
}
const rows: typeof tiles[] = [];
tiles.forEach((tile, index) => {
const rowIndex = Math.floor(index / 2);
if (!rows[rowIndex]) {
rows[rowIndex] = [];
}
rows[rowIndex].push(tile);
});
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('events.detail.managementTitle', 'Event management')}
</Text>
<XStack flexWrap="wrap" space="$2">
{tiles.map((tile, index) => (
<ActionTile
key={tile.label}
icon={tile.icon}
label={tile.label}
color={tile.color}
onPress={tile.onPress}
disabled={tile.disabled}
delayMs={index * ADMIN_MOTION.tileStaggerMs}
/>
))}
</XStack>
</YStack>
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.14}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
>
<YStack space="$2.5">
<XStack alignItems="center" justifyContent="space-between">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={textStrong}>
{t('events.detail.managementTitle', 'Event management')}
</Text>
</XStack>
</XStack>
<YGroup space="$2">
{rows.map((row, rowIndex) => (
<YGroup.Item key={`row-${rowIndex}`}>
<XGroup space="$2">
{row.map((tile, index) => (
<XGroup.Item key={tile.label}>
<ActionTile
icon={tile.icon}
label={tile.label}
color={tile.color}
onPress={tile.onPress}
disabled={tile.disabled}
variant="cluster"
delayMs={(rowIndex * 2 + index) * ADMIN_MOTION.tileStaggerMs}
/>
</XGroup.Item>
))}
{row.length === 1 ? <XStack flex={1} /> : null}
</XGroup>
</YGroup.Item>
))}
</YGroup>
</YStack>
</Card>
);
}
@@ -1323,7 +1431,7 @@ function KpiStrip({
tasksEnabled: boolean;
}) {
const { t } = useTranslation('management');
const { textStrong, muted } = useAdminTheme();
const { textStrong, muted, border, surface, surfaceMuted, shadow } = useAdminTheme();
const text = textStrong;
if (!event) return null;
@@ -1349,33 +1457,57 @@ function KpiStrip({
}
return (
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.kpiTitle', 'Key performance indicators')}
</Text>
{loading ? (
<XStack space="$2" flexWrap="wrap">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`kpi-${idx}`} height={90} width="32%" />
))}
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.14}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
>
<YStack space="$2.5">
<XStack alignItems="center" justifyContent="space-between" gap="$2">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('mobileDashboard.kpiTitle', 'Key performance indicators')}
</Text>
</XStack>
<Text fontSize="$xs" color={muted}>
{formatEventDate(event.event_date, locale) ?? ''}
</Text>
</XStack>
) : (
<XStack space="$2" flexWrap="wrap">
{kpis.map((kpi) => (
<KpiTile key={kpi.label} icon={kpi.icon} label={kpi.label} value={kpi.value ?? '—'} />
))}
</XStack>
)}
<Text fontSize="$xs" color={muted}>
{formatEventDate(event.event_date, locale) ?? ''}
</Text>
</YStack>
{loading ? (
<XStack space="$2" flexWrap="wrap">
{Array.from({ length: 3 }).map((_, idx) => (
<MobileCard key={`kpi-${idx}`} height={90} width="32%" />
))}
</XStack>
) : (
<XStack space="$2" flexWrap="wrap">
{kpis.map((kpi) => (
<KpiTile key={kpi.label} icon={kpi.icon} label={kpi.label} value={kpi.value ?? '—'} />
))}
</XStack>
)}
</YStack>
</Card>
);
}
function AlertsAndHints({ event, stats, tasksEnabled }: { event: TenantEvent | null; stats: EventStats | null | undefined; tasksEnabled: boolean }) {
const { t } = useTranslation('management');
const { textStrong, warningBg, warningBorder, warningText } = useAdminTheme();
const { textStrong, warningBg, warningBorder, warningText, border, surface, surfaceMuted, shadow } = useAdminTheme();
const text = textStrong;
if (!event) return null;
@@ -1392,17 +1524,50 @@ function AlertsAndHints({ event, stats, tasksEnabled }: { event: TenantEvent | n
}
return (
<YStack space="$1.5">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('mobileDashboard.alertsTitle', 'Alerts')}
</Text>
{alerts.map((alert) => (
<MobileCard key={alert} backgroundColor={warningBg} borderColor={warningBorder} space="$2">
<Text fontSize="$sm" color={warningText}>
{alert}
</Text>
</MobileCard>
))}
</YStack>
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.14}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
>
<YStack space="$2.5">
<XStack alignItems="center" justifyContent="space-between">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('mobileDashboard.alertsTitle', 'Alerts')}
</Text>
</XStack>
</XStack>
<YStack space="$2">
{alerts.map((alert) => (
<XStack
key={alert}
padding="$2.5"
borderRadius={16}
borderWidth={1}
borderColor={warningBorder}
backgroundColor={warningBg}
>
<Text fontSize="$sm" color={warningText}>
{alert}
</Text>
</XStack>
))}
</YStack>
</YStack>
</Card>
);
}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { RefreshCcw, Plus, Pencil, Trash2, ChevronDown, ChevronRight } from 'lucide-react';
import { Card } from '@tamagui/card';
import { YStack, XStack } from '@tamagui/stacks';
import { YGroup } from '@tamagui/group';
import { SizableText as Text } from '@tamagui/text';
@@ -9,8 +10,9 @@ import { ListItem } from '@tamagui/list-item';
import { Pressable } from '@tamagui/react-native-web-lite';
import { Button } from '@tamagui/button';
import { AlertDialog } from '@tamagui/alert-dialog';
import { ScrollView } from '@tamagui/scroll-view';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard, PillBadge, FloatingActionButton } from './components/Primitives';
import { MobileCard, CTAButton, SkeletonCard, FloatingActionButton } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect, MobileTextArea } from './components/FormControls';
import {
getEvent,
@@ -43,82 +45,183 @@ import { RadioGroup } from '@tamagui/radio-group';
import { useBackNavigation } from './hooks/useBackNavigation';
import { buildTaskSummary } from './lib/taskSummary';
import { buildTaskSectionCounts, type TaskSectionKey } from './lib/taskSectionCounts';
import { useAdminTheme } from './theme';
import { withAlpha } from './components/colors';
import { ADMIN_ACTION_COLORS, useAdminTheme } from './theme';
function TaskSummaryCard({
summary,
text,
muted,
border,
surfaceMuted,
}: {
summary: ReturnType<typeof buildTaskSummary>;
text: string;
muted: string;
border: string;
surfaceMuted: string;
}) {
function TaskSummaryCard({ summary }: { summary: ReturnType<typeof buildTaskSummary> }) {
const { t } = useTranslation('management');
const { textStrong, muted, border, surface, surfaceMuted, shadow } = useAdminTheme();
const total = summary.assigned + summary.library + summary.collections + summary.emotions;
const segments = [
{
key: 'assigned',
label: t('events.tasks.summary.assigned', 'Assigned'),
value: summary.assigned,
color: ADMIN_ACTION_COLORS.tasks,
},
{
key: 'library',
label: t('events.tasks.summary.library', 'Library'),
value: summary.library,
color: ADMIN_ACTION_COLORS.qr,
},
{
key: 'collections',
label: t('events.tasks.summary.collections', 'Collections'),
value: summary.collections,
color: ADMIN_ACTION_COLORS.settings,
},
{
key: 'emotions',
label: t('events.tasks.summary.emotions', 'Emotions'),
value: summary.emotions,
color: ADMIN_ACTION_COLORS.branding,
},
];
return (
<MobileCard space="$2" borderColor={border}>
<XStack alignItems="center" justifyContent="space-between" space="$2">
<SummaryItem
label={t('events.tasks.summary.assigned', 'Assigned')}
value={summary.assigned}
text={text}
muted={muted}
surfaceMuted={surfaceMuted}
/>
<SummaryItem
label={t('events.tasks.summary.library', 'Library')}
value={summary.library}
text={text}
muted={muted}
surfaceMuted={surfaceMuted}
/>
</XStack>
<XStack alignItems="center" justifyContent="space-between" space="$2">
<SummaryItem
label={t('events.tasks.summary.collections', 'Collections')}
value={summary.collections}
text={text}
muted={muted}
surfaceMuted={surfaceMuted}
/>
<SummaryItem
label={t('events.tasks.summary.emotions', 'Emotions')}
value={summary.emotions}
text={text}
muted={muted}
surfaceMuted={surfaceMuted}
/>
</XStack>
</MobileCard>
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
shadowColor={shadow}
shadowOpacity={0.14}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
>
<YStack space="$2.5">
<XStack alignItems="center" justifyContent="space-between">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize="$xs" fontWeight="800" color={textStrong}>
{t('events.tasks.summary.title', 'Task overview')}
</Text>
</XStack>
</XStack>
<XStack alignItems="baseline" space="$2">
<Text fontSize="$xl" fontWeight="900" color={textStrong}>
{total}
</Text>
<Text fontSize="$xs" color={muted}>
{t('events.tasks.summary.total', 'Tasks total')}
</Text>
</XStack>
<XStack
height={10}
borderRadius={999}
overflow="hidden"
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
{total > 0 ? (
segments.map((segment) =>
segment.value > 0 ? (
<XStack
key={segment.key}
flex={segment.value}
backgroundColor={withAlpha(segment.color, 0.55)}
/>
) : null,
)
) : (
<XStack flex={1} backgroundColor={withAlpha(border, 0.4)} />
)}
</XStack>
<XStack flexWrap="wrap" space="$2">
{segments.map((segment) => (
<SummaryLegendItem key={segment.key} label={segment.label} value={segment.value} color={segment.color} />
))}
</XStack>
</YStack>
</Card>
);
}
function SummaryItem({
function SummaryLegendItem({
label,
value,
text,
muted,
surfaceMuted,
color,
}: {
label: string;
value: number;
text: string;
muted: string;
surfaceMuted: string;
color: string;
}) {
const { textStrong } = useAdminTheme();
return (
<YStack flex={1} padding="$2" borderRadius={12} backgroundColor={surfaceMuted} space="$1">
<Text fontSize={11} color={muted}>
<XStack
alignItems="center"
space="$1.5"
paddingHorizontal="$2.5"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={withAlpha(color, 0.35)}
backgroundColor={withAlpha(color, 0.12)}
>
<XStack width={8} height={8} borderRadius={999} backgroundColor={color} />
<Text fontSize={11} fontWeight="700" color={textStrong}>
{label}
</Text>
<Text fontSize={16} fontWeight="800" color={text}>
<Text fontSize={11} fontWeight="700" color={textStrong}>
{value}
</Text>
</YStack>
</XStack>
);
}
function QuickNavChip({
label,
count,
onPress,
}: {
label: string;
count: number;
onPress: () => void;
}) {
const { textStrong, border, surface, surfaceMuted } = useAdminTheme();
return (
<Pressable onPress={onPress}>
<XStack
alignItems="center"
space="$1.5"
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
style={{ minHeight: 36 }}
>
<Text fontSize="$xs" fontWeight="700" color={textStrong}>
{label}
</Text>
<XStack
paddingHorizontal="$2"
paddingVertical="$0.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<Text fontSize={10} fontWeight="800" color={textStrong}>
{count}
</Text>
</XStack>
</XStack>
</Pressable>
);
}
@@ -491,45 +594,82 @@ export default function MobileEventTasksPage() {
) : null}
{!loading ? (
<TaskSummaryCard
summary={summary}
text={text}
muted={muted}
border={border}
surfaceMuted={surfaceMuted}
/>
<TaskSummaryCard summary={summary} />
) : null}
{!loading ? (
<YStack space="$2">
<Text fontSize={12} fontWeight="700" color={muted}>
{t('events.tasks.quickNav', 'Quick jump')}
</Text>
<XStack space="$2" flexWrap="wrap">
{sectionCounts.map((section) => (
<Button
key={section.key}
unstyled
onPress={() => handleQuickNav(section.key)}
borderRadius={14}
<Card
borderRadius={22}
borderWidth={2}
borderColor={border}
backgroundColor={surface}
padding="$3"
>
<YStack space="$2.5">
<XStack alignItems="center" justifyContent="space-between">
<XStack
alignItems="center"
paddingHorizontal="$3"
paddingVertical="$1.5"
borderRadius={999}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
paddingVertical="$2"
paddingHorizontal="$3"
pressStyle={{ backgroundColor: surfaceMuted }}
style={{ flexGrow: 1 }}
backgroundColor={surfaceMuted}
>
<XStack alignItems="center" justifyContent="center" space="$1.5">
<Text fontSize="$xs" fontWeight="700" color={text}>
{t(`events.tasks.sections.${section.key}`, section.key)}
<Text fontSize="$xs" fontWeight="800" color={text}>
{t('events.tasks.quickNav', 'Quick jump')}
</Text>
</XStack>
</XStack>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<XStack space="$2" paddingVertical="$1">
{sectionCounts.map((section) => (
<QuickNavChip
key={section.key}
label={t(`events.tasks.sections.${section.key}`, section.key)}
count={section.count}
onPress={() => handleQuickNav(section.key)}
/>
))}
</XStack>
</ScrollView>
<XStack alignItems="center" space="$2">
<XStack flex={1}>
<MobileInput
type="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('events.tasks.search', 'Search tasks')}
compact
/>
</XStack>
<Pressable onPress={() => setShowEmotionFilterSheet(true)}>
<XStack
alignItems="center"
space="$1.5"
paddingVertical="$2"
paddingHorizontal="$3"
borderRadius={14}
borderWidth={1}
borderColor={border}
backgroundColor={surface}
>
<Text fontSize={11} fontWeight="700" color={text}>
{t('events.tasks.emotionFilterShort', 'Emotion')}
</Text>
<PillBadge tone="muted">{section.count}</PillBadge>
<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>
<ChevronDown size={14} color={muted} />
</XStack>
</Button>
))}
</XStack>
</YStack>
</Pressable>
</XStack>
</YStack>
</Card>
) : null}
{loading ? (
@@ -631,33 +771,6 @@ export default function MobileEventTasksPage() {
) : (
<YStack space="$2">
<div ref={assignedRef} />
<YStack space="$2">
<MobileInput
type="search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('events.tasks.search', 'Search tasks')}
compact
/>
<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={muted}>
{t('events.tasks.count', '{{count}} Tasks', { count: filteredTasks.length })}
</Text>

View File

@@ -59,7 +59,7 @@ const BACKGROUND_PRESETS = [
},
];
const DEFAULT_BODY_FONT = 'Manrope';
const DEFAULT_DISPLAY_FONT = 'Fraunces';
const DEFAULT_DISPLAY_FONT = 'Archivo Black';
export default function MobileQrLayoutCustomizePage() {
const { slug: slugParam, tokenId: tokenParam } = useParams<{ slug?: string; tokenId?: string }>();
@@ -1206,7 +1206,7 @@ function LayoutControls({
const [openColorSlot, setOpenColorSlot] = React.useState<string | null>(null);
const { border, surface, surfaceMuted, text, textStrong, muted, overlay, shadow, danger } = useAdminTheme();
const fontOptions = React.useMemo(() => {
const preset = ['Fraunces', 'Manrope', 'Inter', 'Roboto', 'Lora'];
const preset = ['Archivo Black', 'Manrope', 'Inter', 'Roboto', 'Lora'];
const tenant = tenantFonts.map((font) => font.family);
return Array.from(new Set([...tenant, ...preset]));
}, [tenantFonts]);

View File

@@ -65,6 +65,11 @@ vi.mock('../theme', () => ({
backdrop: '#0f172a',
dangerBg: '#fee2e2',
dangerText: '#b91c1c',
glassSurface: 'rgba(255,255,255,0.8)',
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
glassBorder: 'rgba(226,232,240,0.7)',
glassShadow: 'rgba(15,23,42,0.14)',
appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
}),
}));

View File

@@ -0,0 +1,235 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const fixtures = vi.hoisted(() => ({
event: {
id: 1,
name: 'Demo Wedding',
slug: 'demo-event',
event_date: '2026-02-19',
status: 'published' as const,
settings: { location: 'Berlin' },
tasks_count: 4,
photo_count: 12,
active_invites_count: 3,
total_invites_count: 5,
},
activePackage: {
id: 1,
package_id: 1,
package_name: 'Standard',
package_type: 'standard',
included_package_slug: null,
active: true,
used_events: 2,
remaining_events: 3,
price: null,
currency: null,
purchased_at: null,
expires_at: null,
package_limits: null,
branding_allowed: true,
watermark_allowed: true,
features: [],
},
}));
const navigateMock = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
useLocation: () => ({ search: '', pathname: '/event-admin/mobile/dashboard' }),
useParams: () => ({ slug: fixtures.event.slug }),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>, options?: Record<string, unknown>) => {
let text = key;
let resolvedOptions = options;
if (typeof fallback === 'string') {
text = fallback;
} else if (fallback && typeof fallback === 'object') {
resolvedOptions = fallback;
if (typeof fallback.defaultValue === 'string') {
text = fallback.defaultValue;
}
}
if (!resolvedOptions || typeof text !== 'string') {
return text;
}
return text.replace(/\{\{(\w+)\}\}/g, (_, token) => String(resolvedOptions?.[token] ?? ''));
},
i18n: {
language: 'de',
},
}),
initReactI18next: {
type: '3rdParty',
init: () => undefined,
},
}));
vi.mock('@tanstack/react-query', () => ({
useQuery: ({ queryKey }: { queryKey: unknown }) => {
const keyParts = Array.isArray(queryKey) ? queryKey : [queryKey];
const key = keyParts.map(String).join(':');
if (key.includes('packages-overview')) {
return { data: { packages: [], activePackage: fixtures.activePackage }, isLoading: false, isError: false };
}
if (key.includes('onboarding') && key.includes('status')) {
return { data: { steps: { summary_seen_package_id: fixtures.activePackage.id } }, isLoading: false };
}
if (key.includes('dashboard') && key.includes('events')) {
return { data: [fixtures.event], isLoading: false };
}
if (key.includes('dashboard') && key.includes('stats')) {
return { data: null, isLoading: false };
}
return { data: null, isLoading: false, isError: false };
},
}));
vi.mock('../../context/EventContext', () => ({
useEventContext: () => ({
events: [fixtures.event],
activeEvent: fixtures.event,
hasEvents: true,
hasMultipleEvents: false,
isLoading: false,
selectEvent: vi.fn(),
}),
}));
vi.mock('../../auth/context', () => ({
useAuth: () => ({ status: 'unauthenticated' }),
}));
vi.mock('../hooks/useInstallPrompt', () => ({
useInstallPrompt: () => ({ isInstalled: true, canInstall: false, isIos: false, promptInstall: vi.fn() }),
}));
vi.mock('../hooks/useAdminPushSubscription', () => ({
useAdminPushSubscription: () => ({ supported: true, subscribed: true, permission: 'granted', loading: false, enable: vi.fn() }),
}));
vi.mock('../hooks/useDevicePermissions', () => ({
useDevicePermissions: () => ({ storage: 'unavailable', loading: false, requestPersistentStorage: vi.fn() }),
}));
vi.mock('../lib/mobileTour', () => ({
getTourSeen: () => true,
resolveTourStepKeys: () => [],
setTourSeen: vi.fn(),
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Sheet', () => ({
MobileSheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
KpiTile: ({ label, value }: { label: string; value: string | number }) => (
<div>
<span>{label}</span>
<span>{value}</span>
</div>
),
ActionTile: ({ label }: { label: string }) => <div>{label}</div>,
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SkeletonCard: () => <div>Loading...</div>,
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/progress', () => ({
Progress: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Indicator: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('@tamagui/group', () => ({
XGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{children}
</button>
),
}));
vi.mock('../theme', () => ({
ADMIN_ACTION_COLORS: {
settings: '#10b981',
tasks: '#f59e0b',
qr: '#6366f1',
images: '#ec4899',
liveShow: '#f97316',
liveShowSettings: '#38bdf8',
guests: '#22c55e',
guestMessages: '#f97316',
branding: '#0ea5e9',
photobooth: '#f43f5e',
recap: '#94a3b8',
analytics: '#22c55e',
},
ADMIN_MOTION: {
tileStaggerMs: 0,
},
useAdminTheme: () => ({
textStrong: '#0f172a',
muted: '#64748b',
border: '#e2e8f0',
surface: '#ffffff',
accentSoft: '#eef2ff',
primary: '#ff5a5f',
surfaceMuted: '#f8fafc',
shadow: 'rgba(15,23,42,0.12)',
}),
}));
vi.mock('../../lib/events', () => ({
resolveEventDisplayName: () => fixtures.event.name,
resolveEngagementMode: () => 'full',
isBrandingAllowed: () => true,
formatEventDate: () => '19. Feb. 2026',
}));
vi.mock('../eventDate', () => ({
isPastEvent: () => false,
}));
import MobileDashboardPage from '../DashboardPage';
describe('MobileDashboardPage', () => {
it('shows package usage progress when a limit is available', () => {
render(<MobileDashboardPage />);
expect(screen.getByText('2 of 5 events used')).toBeInTheDocument();
expect(screen.getByText('3 remaining')).toBeInTheDocument();
});
});

View File

@@ -79,6 +79,11 @@ vi.mock('../theme', () => ({
border: '#e5e7eb',
surface: '#ffffff',
primary: '#ff5a5f',
glassSurface: 'rgba(255,255,255,0.8)',
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
glassBorder: 'rgba(229,231,235,0.7)',
glassShadow: 'rgba(15,23,42,0.14)',
appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
}),
}));

View File

@@ -0,0 +1,217 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const fixtures = vi.hoisted(() => ({
event: {
id: 1,
name: 'Demo Event',
slug: 'demo-event',
event_date: '2026-02-19',
event_type_id: null,
event_type: null,
status: 'published',
settings: {},
},
assignedTasks: [
{ id: 1, title: 'Task A', description: 'Desc', emotion: null, event_type_id: null },
{ id: 2, title: 'Task B', description: '', emotion: null, event_type_id: null },
],
libraryTasks: [
{ id: 3, title: 'Task C', description: '', emotion: null, event_type_id: null },
],
collections: [{ id: 1, name: 'Starter Pack', description: '' }],
emotions: [{ id: 1, name: 'Joy', color: '#ff6b6b' }],
}));
const navigateMock = vi.fn();
const backMock = vi.fn();
vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock,
useParams: () => ({ slug: fixtures.event.slug }),
}));
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') {
return fallback;
}
if (fallback && typeof fallback === 'object' && typeof fallback.defaultValue === 'string') {
return fallback.defaultValue;
}
return key;
},
}),
}));
vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => backMock,
}));
vi.mock('../../context/EventContext', () => ({
useEventContext: () => ({
activeEvent: fixtures.event,
selectEvent: vi.fn(),
}),
}));
vi.mock('../../api', () => ({
getEvent: vi.fn().mockResolvedValue(fixtures.event),
getEvents: vi.fn().mockResolvedValue([fixtures.event]),
getEventTasks: vi.fn().mockResolvedValue({ data: fixtures.assignedTasks }),
getTasks: vi.fn().mockResolvedValue({ data: fixtures.libraryTasks }),
getTaskCollections: vi.fn().mockResolvedValue({ data: fixtures.collections }),
getEmotions: vi.fn().mockResolvedValue(fixtures.emotions),
assignTasksToEvent: vi.fn(),
updateTask: vi.fn(),
importTaskCollection: vi.fn(),
createTask: vi.fn(),
detachTasksFromEvent: vi.fn(),
createEmotion: vi.fn(),
updateEmotion: vi.fn(),
deleteEmotion: vi.fn(),
}));
vi.mock('react-hot-toast', () => ({
default: {
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock('@tamagui/card', () => ({
Card: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/scroll-view', () => ({
ScrollView: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/group', () => ({
YGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('@tamagui/list-item', () => ({
ListItem: ({ title, subTitle, iconAfter }: { title?: React.ReactNode; subTitle?: React.ReactNode; iconAfter?: React.ReactNode }) => (
<div>
{title}
{subTitle}
{iconAfter}
</div>
),
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{children}
</button>
),
}));
vi.mock('@tamagui/button', () => ({
Button: ({ children, onPress }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{children}
</button>
),
}));
vi.mock('@tamagui/radio-group', () => ({
RadioGroup: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Item: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Indicator: () => <div />,
}),
}));
vi.mock('@tamagui/alert-dialog', () => ({
AlertDialog: Object.assign(({ children }: { children: React.ReactNode }) => <div>{children}</div>, {
Portal: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Overlay: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Title: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Description: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Cancel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Action: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}),
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
HeaderActionButton: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
<button type="button" onClick={onPress}>
{label}
</button>
),
SkeletonCard: () => <div>Loading...</div>,
FloatingActionButton: ({ label }: { label: string }) => <div>{label}</div>,
}));
vi.mock('../components/FormControls', () => ({
MobileField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
MobileInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
MobileSelect: ({ children }: { children: React.ReactNode }) => <select>{children}</select>,
MobileTextArea: (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) => <textarea {...props} />,
}));
vi.mock('../components/Sheet', () => ({
MobileSheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Tag', () => ({
Tag: ({ label }: { label: string }) => <span>{label}</span>,
}));
vi.mock('../theme', () => ({
ADMIN_ACTION_COLORS: {
settings: '#0ea5e9',
tasks: '#f59e0b',
qr: '#6366f1',
branding: '#22c55e',
},
useAdminTheme: () => ({
textStrong: '#111827',
muted: '#6b7280',
subtle: '#94a3b8',
border: '#e5e7eb',
primary: '#ff5a5f',
danger: '#dc2626',
surface: '#ffffff',
surfaceMuted: '#f9fafb',
dangerBg: '#fee2e2',
dangerText: '#b91c1c',
overlay: 'rgba(15,23,42,0.5)',
shadow: 'rgba(15,23,42,0.12)',
}),
}));
import MobileEventTasksPage from '../EventTasksPage';
describe('MobileEventTasksPage', () => {
it('renders the task overview summary and quick jump chips', async () => {
render(<MobileEventTasksPage />);
expect(await screen.findByText('Task overview')).toBeInTheDocument();
expect(screen.getByText('Tasks total')).toBeInTheDocument();
expect(screen.getByText('Quick jump')).toBeInTheDocument();
expect(screen.getByText('Assigned')).toBeInTheDocument();
});
});

View File

@@ -69,6 +69,11 @@ vi.mock('../theme', () => ({
surface: '#ffffff',
primary: '#ff5a5f',
accentSoft: '#fde7ea',
glassSurface: 'rgba(255,255,255,0.8)',
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
glassBorder: 'rgba(229,231,235,0.7)',
glassShadow: 'rgba(15,23,42,0.14)',
appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
}),
}));

View File

@@ -110,6 +110,11 @@ vi.mock('../theme', () => ({
primary: '#ff5a5f',
danger: '#b91c1c',
accentSoft: '#ffe5ec',
glassSurface: 'rgba(255,255,255,0.8)',
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
glassBorder: 'rgba(229,231,235,0.7)',
glassShadow: 'rgba(15,23,42,0.14)',
appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
}),
}));

View File

@@ -16,9 +16,11 @@ export type NavKey = 'home' | 'tasks' | 'uploads' | 'profile';
export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate: (key: NavKey) => void }) {
const { t } = useTranslation('mobile');
const location = useLocation();
const { surface, border, primary, accentSoft, muted, subtle, shadow } = useAdminTheme();
const surfaceColor = surface;
const navSurface = withAlpha(surfaceColor, 0.92);
const { surface, border, primary, accent, muted, subtle, shadow, glassSurfaceStrong, glassBorder, glassShadow } = useAdminTheme();
const surfaceColor = glassSurfaceStrong ?? surface;
const navSurface = glassSurfaceStrong ?? withAlpha(surfaceColor, 0.92);
const navBorder = glassBorder ?? border;
const navShadow = glassShadow ?? shadow;
const [pressedKey, setPressedKey] = React.useState<NavKey | null>(null);
const isDeepHome = active === 'home' && location.pathname !== adminPath('/mobile/dashboard');
@@ -38,14 +40,14 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
right={0}
backgroundColor={navSurface}
borderTopWidth={1}
borderColor={border}
borderColor={navBorder}
paddingVertical="$2"
paddingHorizontal="$4"
zIndex={50}
shadowColor={shadow}
shadowOpacity={0.08}
shadowRadius={12}
shadowOffset={{ width: 0, height: -4 }}
shadowColor={navShadow}
shadowOpacity={0.12}
shadowRadius={16}
shadowOffset={{ width: 0, height: -6 }}
// allow for safe-area inset on modern phones
style={{
paddingBottom: 'max(env(safe-area-inset-bottom, 0px), 8px)',
@@ -58,6 +60,8 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
const activeState = item.key === active;
const isPressed = pressedKey === item.key;
const IconCmp = item.icon;
const activeBg = primary;
const activeShadow = withAlpha(primary, 0.4);
return (
<Pressable
key={item.key}
@@ -78,9 +82,10 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius={12}
backgroundColor={activeState ? accentSoft : 'transparent'}
backgroundColor={activeState ? activeBg : 'transparent'}
gap="$1"
style={{
boxShadow: activeState ? `0 10px 22px ${activeShadow}` : undefined,
transform: isPressed ? 'scale(0.96)' : 'scale(1)',
opacity: isPressed ? 0.9 : 1,
transition: 'transform 140ms ease, background-color 140ms ease, opacity 140ms ease',
@@ -93,17 +98,17 @@ export function BottomNav({ active, onNavigate }: { active: NavKey; onNavigate:
width={28}
height={3}
borderRadius={999}
backgroundColor={primary}
backgroundColor={accent}
/>
) : null}
<YStack width={ICON_SIZE} height={ICON_SIZE} alignItems="center" justifyContent="center" shrink={0}>
<IconCmp size={ICON_SIZE} color={activeState ? primary : subtle} />
<IconCmp size={ICON_SIZE} color={activeState ? 'white' : subtle} />
</YStack>
<Text
fontSize="$xs"
fontWeight="700"
fontFamily="$body"
color={activeState ? primary : muted}
color={activeState ? 'white' : muted}
textAlign="center"
flexShrink={1}
>

View File

@@ -37,13 +37,32 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
const { t } = useTranslation('mobile');
const { count: notificationCount } = useNotificationsBadge();
const online = useOnlineStatus();
const { background, surface, border, text, muted, warningBg, warningText, primary, danger, shadow } = useAdminTheme();
const {
background,
surface,
border,
text,
muted,
warningBg,
warningText,
primary,
danger,
shadow,
glassSurfaceStrong,
glassBorder,
glassShadow,
appBackground,
} = useAdminTheme();
const backgroundColor = background;
const surfaceColor = surface;
const borderColor = border;
const textColor = text;
const mutedText = muted;
const headerSurface = withAlpha(surfaceColor, 0.94);
const headerSurface = glassSurfaceStrong ?? withAlpha(surfaceColor, 0.94);
const headerBorder = glassBorder ?? borderColor;
const actionSurface = glassSurfaceStrong ?? surfaceColor;
const actionBorder = glassBorder ?? borderColor;
const actionShadow = glassShadow ?? shadow;
const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [loadingEvents, setLoadingEvents] = React.useState(false);
const [attemptedFetch, setAttemptedFetch] = React.useState(false);
@@ -152,10 +171,17 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
width={34}
height={34}
borderRadius={12}
backgroundColor={surfaceColor}
backgroundColor={actionSurface}
borderWidth={1}
borderColor={actionBorder}
alignItems="center"
justifyContent="center"
position="relative"
style={{
boxShadow: `0 10px 18px ${actionShadow}`,
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
}}
>
<Bell size={16} color={textColor} />
{notificationCount > 0 ? (
@@ -190,6 +216,7 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
backgroundColor={primary}
alignItems="center"
justifyContent="center"
style={{ boxShadow: `0 10px 18px ${withAlpha(primary, 0.32)}` }}
>
<QrCode size={16} color="white" />
</XStack>
@@ -200,18 +227,23 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head
);
return (
<YStack backgroundColor={backgroundColor} minHeight="100vh" alignItems="center">
<YStack
backgroundColor={backgroundColor}
minHeight="100vh"
alignItems="center"
style={{ background: appBackground }}
>
<YStack
backgroundColor={headerSurface}
borderBottomWidth={1}
borderColor={borderColor}
borderColor={headerBorder}
paddingHorizontal="$4"
paddingTop="$4"
paddingBottom="$3"
shadowColor={shadow}
shadowOpacity={0.06}
shadowRadius={10}
shadowOffset={{ width: 0, height: 4 }}
shadowColor={actionShadow}
shadowOpacity={0.08}
shadowRadius={14}
shadowOffset={{ width: 0, height: 8 }}
width="100%"
maxWidth={800}
position="sticky"

View File

@@ -2,27 +2,32 @@ import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Pressable } from '@tamagui/react-native-web-lite';
import { useAdminTheme } from '../theme';
import { ADMIN_GRADIENTS, useAdminTheme } from '../theme';
import { withAlpha } from './colors';
export function MobileCard({
children,
className,
style,
...rest
}: React.ComponentProps<typeof YStack>) {
const { surface, border, shadow } = useAdminTheme();
const { surface, border, shadow, glassSurface, glassBorder, glassShadow } = useAdminTheme();
return (
<YStack
className={['admin-fade-up', className].filter(Boolean).join(' ')}
backgroundColor={surface}
borderRadius={18}
borderWidth={1}
borderColor={border}
shadowColor={shadow}
shadowOpacity={0.08}
shadowRadius={14}
backgroundColor={glassSurface ?? surface}
borderRadius={20}
borderWidth={2}
borderColor={glassBorder ?? border}
shadowColor={glassShadow ?? shadow}
shadowOpacity={0.16}
shadowRadius={16}
shadowOffset={{ width: 0, height: 10 }}
padding="$3.5"
space="$2"
style={{
...style,
}}
{...rest}
>
{children}
@@ -99,13 +104,18 @@ export function CTAButton({
iconLeft?: React.ReactNode;
iconRight?: React.ReactNode;
}) {
const { primary, surface, border, text, danger } = useAdminTheme();
const { primary, surface, border, text, danger, glassSurfaceStrong } = useAdminTheme();
const isPrimary = tone === 'primary';
const isDanger = tone === 'danger';
const isDisabled = disabled || loading;
const backgroundColor = isDanger ? danger : isPrimary ? primary : surface;
const backgroundColor = isDanger ? danger : isPrimary ? primary : glassSurfaceStrong ?? surface;
const borderColor = isPrimary || isDanger ? 'transparent' : border;
const labelColor = isPrimary || isDanger ? 'white' : text;
const primaryStyle = isPrimary
? {
boxShadow: `0 18px 28px ${withAlpha(primary, 0.4)}`,
}
: undefined;
return (
<Pressable
onPress={isDisabled ? undefined : onPress}
@@ -119,13 +129,14 @@ export function CTAButton({
>
<XStack
height={52}
borderRadius={16}
borderRadius={18}
alignItems="center"
justifyContent="center"
backgroundColor={backgroundColor}
borderWidth={isPrimary || isDanger ? 0 : 1}
borderWidth={isPrimary || isDanger ? 0 : 2}
borderColor={borderColor}
space="$2"
style={primaryStyle}
>
{iconLeft}
<Text fontSize="$sm" fontWeight="800" color={labelColor}>
@@ -183,6 +194,7 @@ export function ActionTile({
color,
onPress,
disabled = false,
variant = 'grid',
delayMs = 0,
}: {
icon: React.ComponentType<{ size?: number; color?: string }>;
@@ -190,49 +202,56 @@ export function ActionTile({
color: string;
onPress?: () => void;
disabled?: boolean;
variant?: 'grid' | 'cluster';
delayMs?: number;
}) {
const { textStrong } = useAdminTheme();
const backgroundColor = `${color}18`;
const borderColor = `${color}40`;
const shadowColor = `${color}2b`;
const iconShadow = `${color}55`;
const { textStrong, glassSurface } = useAdminTheme();
const isCluster = variant === 'cluster';
const backgroundColor = withAlpha(color, 0.12);
const borderColor = withAlpha(color, 0.4);
const shadowColor = withAlpha(color, 0.35);
const iconShadow = withAlpha(color, 0.5);
const tileStyle = {
...(delayMs ? { animationDelay: `${delayMs}ms` } : {}),
backgroundImage: `linear-gradient(135deg, ${backgroundColor}, ${color}0f)`,
boxShadow: `0 10px 24px ${shadowColor}`,
backgroundImage: `linear-gradient(135deg, ${backgroundColor}, ${withAlpha(color, 0.08)})`,
boxShadow: isCluster ? `0 12px 18px ${shadowColor}` : `0 20px 30px ${shadowColor}`,
};
return (
<Pressable
onPress={disabled ? undefined : onPress}
style={{ width: '48%', marginBottom: 12, opacity: disabled ? 0.5 : 1 }}
style={{
width: isCluster ? '100%' : '48%',
flex: isCluster ? 1 : undefined,
marginBottom: isCluster ? 0 : 12,
opacity: disabled ? 0.5 : 1,
}}
disabled={disabled}
>
<YStack
className="admin-fade-up"
style={tileStyle}
borderRadius={16}
borderRadius={isCluster ? 14 : 16}
padding="$3"
space="$2.5"
backgroundColor={backgroundColor}
borderWidth={1}
backgroundColor={glassSurface ?? backgroundColor}
borderWidth={2}
borderColor={borderColor}
minHeight={110}
minHeight={120}
alignItems="center"
justifyContent="center"
>
<XStack
width={36}
height={36}
borderRadius={12}
width={44}
height={44}
borderRadius={14}
backgroundColor={color}
alignItems="center"
justifyContent="center"
style={{ boxShadow: `0 6px 14px ${iconShadow}` }}
>
<IconCmp size={16} color="white" />
<IconCmp size={18} color="white" />
</XStack>
<Text fontSize="$sm" fontWeight="700" color={textStrong} textAlign="center">
<Text fontSize="$sm" fontWeight="800" color={textStrong} textAlign="center">
{label}
</Text>
</YStack>

View File

@@ -17,11 +17,12 @@ type MobileScaffoldProps = {
export function MobileScaffold({ title, onBack, rightSlot, children, footer }: MobileScaffoldProps) {
const { t } = useTranslation('mobile');
const { background, surface, border, text, primary } = useAdminTheme();
const headerSurface = withAlpha(surface, 0.94);
const { background, surface, border, text, primary, glassSurfaceStrong, glassBorder, appBackground } = useAdminTheme();
const headerSurface = glassSurfaceStrong ?? withAlpha(surface, 0.94);
const headerBorder = glassBorder ?? border;
return (
<YStack backgroundColor={background} minHeight="100vh">
<YStack backgroundColor={background} minHeight="100vh" style={{ background: appBackground }}>
<XStack
alignItems="center"
justifyContent="space-between"
@@ -30,7 +31,7 @@ export function MobileScaffold({ title, onBack, rightSlot, children, footer }: M
paddingBottom="$3"
backgroundColor={headerSurface}
borderBottomWidth={1}
borderColor={border}
borderColor={headerBorder}
position="sticky"
top={0}
zIndex={60}

View File

@@ -92,6 +92,11 @@ vi.mock('../../theme', () => ({
primary: '#FF5A5F',
danger: '#b91c1c',
shadow: 'rgba(0,0,0,0.12)',
glassSurface: 'rgba(255,255,255,0.8)',
glassSurfaceStrong: 'rgba(255,255,255,0.9)',
glassBorder: 'rgba(229,231,235,0.7)',
glassShadow: 'rgba(15,23,42,0.14)',
appBackground: 'linear-gradient(180deg, #f7fafc, #eef3f7)',
}),
}));

View File

@@ -182,7 +182,7 @@ const DEFAULT_PRESET: LayoutPreset = [
height: 220,
fontSize: 110,
align: 'center',
fontFamily: 'Fraunces',
fontFamily: 'Archivo Black',
lineHeight: 1.3,
},
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 580, width: 800, height: 120, fontSize: 42, align: 'center', fontFamily: 'Manrope', lineHeight: 1.4 },
@@ -202,7 +202,7 @@ const evergreenVowsPreset: LayoutPreset = [
height: 200,
fontSize: 95,
align: 'left',
fontFamily: 'Fraunces',
fontFamily: 'Archivo Black',
lineHeight: 1.3,
},
{
@@ -251,7 +251,7 @@ const midnightGalaPreset: LayoutPreset = [
height: 220,
fontSize: 105,
align: 'center',
fontFamily: 'Fraunces',
fontFamily: 'Archivo Black',
lineHeight: 1.3,
},
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 900) / 2, y: 480, width: 900, height: 120, fontSize: 40, align: 'center', fontFamily: 'Manrope', lineHeight: 1.4 },
@@ -269,7 +269,7 @@ const midnightGalaPreset: LayoutPreset = [
const gardenBrunchPreset: LayoutPreset = [
// Verspielt, asymmetrisch, aber ausbalanciert
{ id: 'headline', type: 'headline', x: 120, y: 240, width: 900, height: 200, fontSize: 90, align: 'left', fontFamily: 'Fraunces', lineHeight: 1.3 },
{ id: 'headline', type: 'headline', x: 120, y: 240, width: 900, height: 200, fontSize: 90, align: 'left', fontFamily: 'Archivo Black', lineHeight: 1.3 },
{ id: 'subtitle', type: 'subtitle', x: 120, y: 450, width: 700, height: 120, fontSize: 40, align: 'left', fontFamily: 'Manrope', lineHeight: 1.4 },
{
id: 'qr',
@@ -305,7 +305,7 @@ const sparklerSoireePreset: LayoutPreset = [
height: 220,
fontSize: 100,
align: 'center',
fontFamily: 'Fraunces',
fontFamily: 'Archivo Black',
lineHeight: 1.3,
},
{ id: 'subtitle', type: 'subtitle', x: (c) => (c.canvasWidth - 800) / 2, y: 480, width: 800, height: 120, fontSize: 40, align: 'center', fontFamily: 'Manrope' },
@@ -333,7 +333,7 @@ const confettiBashPreset: LayoutPreset = [
height: 220,
fontSize: 110,
align: 'center',
fontFamily: 'Fraunces',
fontFamily: 'Archivo Black',
lineHeight: 1.3,
},
{
@@ -394,7 +394,7 @@ const balancedModernPreset: LayoutPreset = [
height: 380,
fontSize: 100,
align: 'left',
fontFamily: 'Fraunces',
fontFamily: 'Archivo Black',
lineHeight: 1.3,
},
{

View File

@@ -1,59 +1,120 @@
import { useTheme } from '@tamagui/core';
export const ADMIN_COLORS = {
primary: '#FF5A5F',
primaryStrong: '#C2413B',
accent: '#FFF8F5',
accentSoft: '#FFF1EB',
accentWarm: '#FFE4DA',
warning: '#F5C542',
success: '#06D6A0',
danger: '#B91C1C',
text: '#1F2937',
textMuted: '#6B7280',
textSubtle: '#94A3B8',
border: '#F2E4DA',
primary: '#FF5C5C',
primaryStrong: '#E63B57',
accent: '#3D5AFE',
accentSoft: '#E8ECFF',
accentWarm: '#FFE3D6',
warning: '#FBBF24',
success: '#22C55E',
danger: '#EF4444',
text: '#0B132B',
textMuted: '#54606E',
textSubtle: '#8C99A8',
border: '#F3D6C9',
surface: '#FFFFFF',
surfaceMuted: '#FFFDFB',
backdrop: '#0F172A',
surfaceMuted: '#FFF6F0',
backdrop: '#0B132B',
};
export const ADMIN_ACTION_COLORS = {
settings: '#14B8A6',
tasks: '#F59E0B',
qr: '#3B82F6',
images: '#8B5CF6',
liveShow: '#EC4899',
liveShowSettings: '#0EA5E9',
guests: '#10B981',
guestMessages: '#F97316',
branding: '#6366F1',
photobooth: '#E11D48',
recap: '#64748B',
settings: '#00C2A8',
tasks: '#FFC857',
qr: '#3D5AFE',
images: '#FF7AB6',
liveShow: '#FF6D00',
liveShowSettings: '#00B0FF',
guests: '#22C55E',
guestMessages: '#FF8A00',
branding: '#00B4D8',
photobooth: '#FF3D71',
recap: '#94A3B8',
packages: ADMIN_COLORS.primary,
analytics: '#22C55E',
invites: ADMIN_COLORS.primaryStrong,
invites: '#3D5AFE',
};
export const ADMIN_GRADIENTS = {
primaryCta: `linear-gradient(135deg, ${ADMIN_COLORS.primary}, #FF8A8E, ${ADMIN_COLORS.accent})`,
softCard: `linear-gradient(135deg, ${ADMIN_COLORS.accentSoft}, ${ADMIN_COLORS.accentWarm})`,
loginBackground: 'linear-gradient(135deg, #0b1020, #0f172a, #0b1020)',
primaryCta: `linear-gradient(135deg, ${ADMIN_COLORS.primary}, #FF9A5C, ${ADMIN_COLORS.accent})`,
softCard: 'linear-gradient(145deg, rgba(255,255,255,0.98), rgba(255,245,238,0.92))',
loginBackground: 'linear-gradient(135deg, #0b132b, #162040, #0b132b)',
appBackground:
'radial-gradient(circle at 15% 0%, rgba(255, 92, 92, 0.22), transparent 50%), radial-gradient(circle at 85% 10%, rgba(61, 90, 254, 0.2), transparent 55%), linear-gradient(180deg, #fff4ee 0%, #ffefe4 100%)',
appBackgroundDark:
'radial-gradient(circle at 15% 0%, rgba(255, 92, 92, 0.18), transparent 55%), radial-gradient(circle at 85% 10%, rgba(61, 90, 254, 0.18), transparent 55%), linear-gradient(180deg, #0b132b 0%, #0e1a33 100%)',
};
export const ADMIN_MOTION = {
tileStaggerMs: 40,
};
type Rgb = { r: number; g: number; b: number };
function parseRgb(color: string): Rgb | null {
const trimmed = color.trim();
if (trimmed.startsWith('#')) {
const hex = trimmed.slice(1);
const normalized = hex.length === 3 ? hex.split('').map((ch) => ch + ch).join('') : hex;
if (normalized.length === 6) {
return {
r: Number.parseInt(normalized.slice(0, 2), 16),
g: Number.parseInt(normalized.slice(2, 4), 16),
b: Number.parseInt(normalized.slice(4, 6), 16),
};
}
}
const rgb = trimmed.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/i);
if (rgb) {
return { r: Number(rgb[1]), g: Number(rgb[2]), b: Number(rgb[3]) };
}
const rgba = trimmed.match(/^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([0-9.]+)\)$/i);
if (rgba) {
return { r: Number(rgba[1]), g: Number(rgba[2]), b: Number(rgba[3]) };
}
return null;
}
function withAlpha(color: string, alpha: number): string {
const rgb = parseRgb(color);
if (! rgb) {
return color;
}
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
}
function isDarkColor(color: string): boolean {
const rgb = parseRgb(color);
if (! rgb) {
return false;
}
const luminance = (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255;
return luminance < 0.5;
}
export function useAdminTheme() {
const theme = useTheme();
const background = String(theme.background?.val ?? '#FFF8F5');
const surface = String(theme.surface?.val ?? ADMIN_COLORS.surface);
const border = String(theme.borderColor?.val ?? ADMIN_COLORS.border);
const isDark = isDarkColor(background);
const glassSurface = withAlpha(surface, isDark ? 0.96 : 0.98);
const glassSurfaceStrong = withAlpha(surface, isDark ? 1 : 1);
const glassBorder = withAlpha(border, isDark ? 0.85 : 0.95);
const glassShadow = isDark ? 'rgba(0, 0, 0, 0.55)' : 'rgba(11, 19, 43, 0.18)';
const appBackground = isDark ? ADMIN_GRADIENTS.appBackgroundDark : ADMIN_GRADIENTS.appBackground;
return {
theme,
background: String(theme.background?.val ?? '#FFF8F5'),
surface: String(theme.surface?.val ?? ADMIN_COLORS.surface),
background,
surface,
surfaceMuted: String(theme.gray2?.val ?? ADMIN_COLORS.surfaceMuted),
border: String(theme.borderColor?.val ?? ADMIN_COLORS.border),
border,
text: String(theme.color?.val ?? ADMIN_COLORS.text),
textStrong: String(theme.color12?.val ?? theme.color?.val ?? ADMIN_COLORS.text),
muted: String(theme.gray?.val ?? ADMIN_COLORS.textMuted),
@@ -73,7 +134,12 @@ export function useAdminTheme() {
infoText: String(theme.blue10?.val ?? ADMIN_COLORS.primaryStrong),
danger: String(theme.red10?.val ?? ADMIN_COLORS.danger),
backdrop: String(theme.gray12?.val ?? ADMIN_COLORS.backdrop),
overlay: String(theme.gray12?.val ?? 'rgba(15, 23, 42, 0.6)'),
shadow: String(theme.shadowColor?.val ?? 'rgba(31, 41, 55, 0.12)'),
overlay: withAlpha(String(theme.gray12?.val ?? ADMIN_COLORS.backdrop), 0.6),
shadow: String(theme.shadowColor?.val ?? 'rgba(15, 23, 42, 0.12)'),
glassSurface,
glassSurfaceStrong,
glassBorder,
glassShadow,
appBackground,
};
}

View File

@@ -8,16 +8,16 @@ const tokens = {
...baseTokens,
color: {
...baseTokens.color,
primary: '#FF5A5F',
accent: '#FFB6C1',
accentSoft: '#FFE5EC',
success: '#06D6A0',
warning: '#F5C542',
danger: '#E04848',
primary: '#FF5C5C',
accent: '#3D5AFE',
accentSoft: '#E8ECFF',
success: '#22C55E',
warning: '#FBBF24',
danger: '#EF4444',
surface: '#ffffff',
muted: '#F4ECE8',
border: '#F2E4DA',
text: '#1F2937',
muted: '#FFF6F0',
border: '#F3D6C9',
text: '#0B132B',
},
radius: {
...baseTokens.radius,
@@ -37,53 +37,53 @@ const themes = {
...baseThemes.light,
primary: tokens.color.primary,
accent: tokens.color.accent,
background: '#FFF8F5',
backgroundHover: '#FFF1EC',
backgroundPress: '#FFE7E0',
background: '#FFF1E8',
backgroundHover: '#FFE8DD',
backgroundPress: '#FFE1D2',
backgroundStrong: tokens.color.surface,
backgroundTransparent: 'rgba(255, 248, 245, 0)',
backgroundTransparent: 'rgba(255, 241, 232, 0)',
color: tokens.color.text,
colorHover: '#111827',
colorPress: '#0F172A',
colorFocus: '#0F172A',
colorHover: '#091024',
colorPress: '#091024',
colorFocus: '#091024',
borderColor: tokens.color.border,
borderColorHover: '#EAD5C9',
borderColorPress: '#E0C9BC',
shadowColor: 'rgba(31, 41, 55, 0.12)',
shadowColorPress: 'rgba(31, 41, 55, 0.16)',
shadowColorFocus: 'rgba(31, 41, 55, 0.18)',
borderColorHover: '#EBCABA',
borderColorPress: '#E1BFAE',
shadowColor: 'rgba(11, 19, 43, 0.16)',
shadowColorPress: 'rgba(11, 19, 43, 0.2)',
shadowColorFocus: 'rgba(11, 19, 43, 0.24)',
surface: tokens.color.surface,
muted: tokens.color.muted,
blue3: tokens.color.accentSoft,
blue6: tokens.color.accent,
blue10: tokens.color.primary,
blue11: '#C2413B',
blue11: '#1E36F1',
},
dark: {
...baseThemes.dark,
primary: tokens.color.primary,
accent: tokens.color.accent,
background: '#171219',
backgroundHover: '#1F1A23',
backgroundPress: '#26212B',
backgroundStrong: '#1F1A23',
backgroundTransparent: 'rgba(23, 18, 25, 0)',
color: '#F8F6F2',
background: '#0B132B',
backgroundHover: '#101A36',
backgroundPress: '#132142',
backgroundStrong: '#101A36',
backgroundTransparent: 'rgba(11, 19, 43, 0)',
color: '#F8FAFF',
colorHover: '#FFFFFF',
colorPress: '#FDF8F5',
colorPress: '#F2F6FF',
colorFocus: '#FFFFFF',
borderColor: '#2C2531',
borderColorHover: '#3A3240',
borderColorPress: '#443C4A',
borderColor: '#1F2A4A',
borderColorHover: '#29345A',
borderColorPress: '#313D67',
shadowColor: 'rgba(0, 0, 0, 0.55)',
shadowColorPress: 'rgba(0, 0, 0, 0.65)',
shadowColorFocus: 'rgba(0, 0, 0, 0.6)',
surface: '#1F1A23',
muted: '#241E28',
blue3: '#2B1D23',
blue6: '#5A2D34',
blue10: '#FF7A7F',
blue11: '#FFB3B6',
surface: '#0F1B36',
muted: '#121F3D',
blue3: '#1B2550',
blue6: '#3D5AFE',
blue10: '#FF5C5C',
blue11: '#FF8A8A',
},
};
@@ -105,12 +105,12 @@ const fonts = {
},
heading: {
...defaultConfig.fonts.heading,
family: 'Manrope',
family: 'Archivo Black',
weight: sharedWeights,
},
display: {
...defaultConfig.fonts.heading,
family: 'Fraunces',
family: 'Archivo Black',
weight: sharedWeights,
},
};