mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +00:00
Refactor authentication and account management with modular UI components (#242)
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin "daisyui" {
|
||||
themes: biergarten-lager, biergarten-stout, biergarten-cassis, biergarten-weizen;
|
||||
themes: biergarten-lager, biergarten-stout, biergarten-cassis, biergarten-weizen;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
'DM Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-serif: 'Volkhov', ui-serif, Georgia, serif;
|
||||
--font-sans:
|
||||
'DM Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
--font-serif: 'Volkhov', ui-serif, Georgia, serif;
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -17,7 +17,7 @@ h4,
|
||||
h5,
|
||||
h6,
|
||||
.card-title {
|
||||
font-family: var(--font-serif);
|
||||
font-family: var(--font-serif);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
@@ -26,55 +26,55 @@ h6,
|
||||
primary, softened mahogany secondary.
|
||||
───────────────────────────────────────── */
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'biergarten-lager';
|
||||
default: true;
|
||||
prefersdark: false;
|
||||
color-scheme: 'light';
|
||||
name: 'biergarten-lager';
|
||||
default: true;
|
||||
prefersdark: false;
|
||||
color-scheme: 'light';
|
||||
|
||||
--color-base-100: oklch(96% 0.012 82); /* warm parchment */
|
||||
--color-base-200: oklch(92% 0.018 80); /* brushed paper */
|
||||
--color-base-300: oklch(87% 0.025 78); /* tinted linen */
|
||||
--color-base-content: oklch(28% 0.025 58); /* dark brown ink — 15.6:1 on base-100 */
|
||||
--color-base-100: oklch(96% 0.012 82); /* warm parchment */
|
||||
--color-base-200: oklch(92% 0.018 80); /* brushed paper */
|
||||
--color-base-300: oklch(87% 0.025 78); /* tinted linen */
|
||||
--color-base-content: oklch(28% 0.025 58); /* dark brown ink — 15.6:1 on base-100 */
|
||||
|
||||
--color-primary: oklch(65% 0.085 62); /* mellow amber */
|
||||
--color-primary-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */
|
||||
--color-primary: oklch(65% 0.085 62); /* mellow amber */
|
||||
--color-primary-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */
|
||||
|
||||
--color-secondary: oklch(42% 0.05 42); /* softened mahogany */
|
||||
--color-secondary-content: oklch(96% 0.01 76); /* off-white — 14.2:1 on secondary */
|
||||
--color-secondary: oklch(42% 0.05 42); /* softened mahogany */
|
||||
--color-secondary-content: oklch(96% 0.01 76); /* off-white — 14.2:1 on secondary */
|
||||
|
||||
--color-accent: oklch(93% 0.015 90); /* frothy cream */
|
||||
--color-accent-content: oklch(28% 0.025 58); /* dark brown — 12.8:1 on accent */
|
||||
--color-accent: oklch(93% 0.015 90); /* frothy cream */
|
||||
--color-accent-content: oklch(28% 0.025 58); /* dark brown — 12.8:1 on accent */
|
||||
|
||||
--color-neutral: oklch(28% 0.02 46); /* warm roast dark */
|
||||
--color-neutral-content: oklch(92% 0.012 80); /* pale parchment — 12.0:1 on neutral */
|
||||
--color-neutral: oklch(28% 0.02 46); /* warm roast dark */
|
||||
--color-neutral-content: oklch(92% 0.012 80); /* pale parchment — 12.0:1 on neutral */
|
||||
|
||||
--color-info: oklch(46% 0.065 145); /* muted hop green */
|
||||
--color-info-content: oklch(97% 0.008 145); /* near-white — 14.2:1 on info */
|
||||
--color-info: oklch(46% 0.065 145); /* muted hop green */
|
||||
--color-info-content: oklch(97% 0.008 145); /* near-white — 14.2:1 on info */
|
||||
|
||||
--color-success: oklch(70% 0.06 122); /* soft barley gold */
|
||||
--color-success-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */
|
||||
--color-success: oklch(70% 0.06 122); /* soft barley gold */
|
||||
--color-success-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */
|
||||
|
||||
--color-warning: oklch(72% 0.09 56); /* toned amber */
|
||||
--color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */
|
||||
--color-warning: oklch(72% 0.09 56); /* toned amber */
|
||||
--color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */
|
||||
|
||||
--color-error: oklch(54% 0.09 22); /* restrained cherry */
|
||||
--color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */
|
||||
--color-error: oklch(54% 0.09 22); /* restrained cherry */
|
||||
--color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */
|
||||
|
||||
--color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */
|
||||
--color-surface-content: oklch(28% 0.025 58); /* dark brown — 9.2:1 on surface */
|
||||
--color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */
|
||||
--color-surface-content: oklch(28% 0.025 58); /* dark brown — 9.2:1 on surface */
|
||||
|
||||
--color-muted: oklch(42% 0.055 62); /* amber-brown — 14.2:1 on base-100, 8.3:1 on surface */
|
||||
--color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */
|
||||
--color-highlight-content: oklch(22% 0.025 55); /* dark brown — 4.9:1 on highlight */
|
||||
--color-muted: oklch(42% 0.055 62); /* amber-brown — 14.2:1 on base-100, 8.3:1 on surface */
|
||||
--color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */
|
||||
--color-highlight-content: oklch(22% 0.025 55); /* dark brown — 4.9:1 on highlight */
|
||||
|
||||
--radius-selector: 0.375rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 0.875rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 1;
|
||||
--radius-selector: 0.375rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 0.875rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 1;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
@@ -83,55 +83,55 @@ h6,
|
||||
primary, deep mahogany secondary.
|
||||
───────────────────────────────────────── */
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'biergarten-stout';
|
||||
default: false;
|
||||
prefersdark: true;
|
||||
color-scheme: 'dark';
|
||||
name: 'biergarten-stout';
|
||||
default: false;
|
||||
prefersdark: true;
|
||||
color-scheme: 'dark';
|
||||
|
||||
--color-base-100: oklch(14% 0.006 45); /* charred barrel black */
|
||||
--color-base-200: oklch(18% 0.008 43); /* roasted malt dark */
|
||||
--color-base-300: oklch(23% 0.01 42); /* deep brown */
|
||||
--color-base-content: oklch(88% 0.008 75); /* warm off-white — 9.4:1 on base-100 */
|
||||
--color-base-100: oklch(14% 0.006 45); /* charred barrel black */
|
||||
--color-base-200: oklch(18% 0.008 43); /* roasted malt dark */
|
||||
--color-base-300: oklch(23% 0.01 42); /* deep brown */
|
||||
--color-base-content: oklch(88% 0.008 75); /* warm off-white — 9.4:1 on base-100 */
|
||||
|
||||
--color-primary: oklch(68% 0.055 60); /* golden amber */
|
||||
--color-primary-content: oklch(92% 0.012 50); /* warm off-white — 4.6:1 on primary */
|
||||
--color-primary: oklch(68% 0.055 60); /* golden amber */
|
||||
--color-primary-content: oklch(92% 0.012 50); /* warm off-white — 4.6:1 on primary */
|
||||
|
||||
--color-secondary: oklch(48% 0.035 40); /* deep mahogany ale */
|
||||
--color-secondary-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */
|
||||
--color-secondary: oklch(48% 0.035 40); /* deep mahogany ale */
|
||||
--color-secondary-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */
|
||||
|
||||
--color-accent: oklch(82% 0.01 88); /* frothy cream head */
|
||||
--color-accent-content: oklch(18% 0.012 55); /* near-black — 6.2:1 on accent */
|
||||
--color-accent: oklch(82% 0.01 88); /* frothy cream head */
|
||||
--color-accent-content: oklch(18% 0.012 55); /* near-black — 6.2:1 on accent */
|
||||
|
||||
--color-neutral: oklch(20% 0.008 45); /* near-black with warmth */
|
||||
--color-neutral-content: oklch(88% 0.007 78); /* warm off-white — 9.3:1 on neutral */
|
||||
--color-neutral: oklch(20% 0.008 45); /* near-black with warmth */
|
||||
--color-neutral-content: oklch(88% 0.007 78); /* warm off-white — 9.3:1 on neutral */
|
||||
|
||||
--color-info: oklch(60% 0.04 145); /* cool hop green */
|
||||
--color-info-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */
|
||||
--color-info: oklch(60% 0.04 145); /* cool hop green */
|
||||
--color-info-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */
|
||||
|
||||
--color-success: oklch(66% 0.038 120); /* fresh barley */
|
||||
--color-success-content: oklch(90% 0.012 120); /* pale barley-white — 4.6:1 on success */
|
||||
--color-success: oklch(66% 0.038 120); /* fresh barley */
|
||||
--color-success-content: oklch(90% 0.012 120); /* pale barley-white — 4.6:1 on success */
|
||||
|
||||
--color-warning: oklch(70% 0.055 55); /* amber harvest */
|
||||
--color-warning-content: oklch(94% 0.012 55); /* warm near-white — 4.7:1 on warning */
|
||||
--color-warning: oklch(70% 0.055 55); /* amber harvest */
|
||||
--color-warning-content: oklch(94% 0.012 55); /* warm near-white — 4.7:1 on warning */
|
||||
|
||||
--color-error: oklch(50% 0.06 20); /* deep cherry kriek */
|
||||
--color-error-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */
|
||||
--color-error: oklch(50% 0.06 20); /* deep cherry kriek */
|
||||
--color-error-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */
|
||||
|
||||
--color-surface: oklch(26% 0.012 45); /* elevated dark panel */
|
||||
--color-surface-content: oklch(88% 0.008 75); /* warm off-white — 9.2:1 on surface */
|
||||
--color-surface: oklch(26% 0.012 45); /* elevated dark panel */
|
||||
--color-surface-content: oklch(88% 0.008 75); /* warm off-white — 9.2:1 on surface */
|
||||
|
||||
--color-muted: oklch(78% 0.018 72); /* warm grey — 4.7:1 on base-100, 4.6:1 on surface */
|
||||
--color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */
|
||||
--color-highlight-content: oklch(88% 0.008 75); /* warm off-white — 9.0:1 on highlight */
|
||||
--color-muted: oklch(78% 0.018 72); /* warm grey — 4.7:1 on base-100, 4.6:1 on surface */
|
||||
--color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */
|
||||
--color-highlight-content: oklch(88% 0.008 75); /* warm off-white — 9.0:1 on highlight */
|
||||
|
||||
--radius-selector: 0.375rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 0.875rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 1;
|
||||
--radius-selector: 0.375rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 0.875rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 1;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
@@ -140,57 +140,57 @@ h6,
|
||||
primary, sour cherry secondary.
|
||||
───────────────────────────────────────── */
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'biergarten-cassis';
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
color-scheme: 'dark';
|
||||
name: 'biergarten-cassis';
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
color-scheme: 'dark';
|
||||
|
||||
--color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */
|
||||
--color-base-200: oklch(17% 0.013 292); /* deep purple-black */
|
||||
--color-base-300: oklch(22% 0.016 290); /* dark grape */
|
||||
--color-base-content: oklch(90% 0.014 300); /* pale lavender-white — 10.7:1 on base-100 */
|
||||
--color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */
|
||||
--color-base-200: oklch(17% 0.013 292); /* deep purple-black */
|
||||
--color-base-300: oklch(22% 0.016 290); /* dark grape */
|
||||
--color-base-content: oklch(90% 0.014 300); /* pale lavender-white — 10.7:1 on base-100 */
|
||||
|
||||
--color-primary: oklch(72% 0.075 295); /* cassis berry purple */
|
||||
--color-primary-content: oklch(95% 0.01 295); /* pale lavender — 4.5:1 on primary */
|
||||
--color-primary: oklch(72% 0.075 295); /* cassis berry purple */
|
||||
--color-primary-content: oklch(95% 0.01 295); /* pale lavender — 4.5:1 on primary */
|
||||
|
||||
--color-secondary: oklch(68% 0.06 10); /* sour cherry rose */
|
||||
--color-secondary-content: oklch(92% 0.006 10); /* warm near-white — 4.6:1 on secondary */
|
||||
--color-secondary: oklch(68% 0.06 10); /* sour cherry rose */
|
||||
--color-secondary-content: oklch(92% 0.006 10); /* warm near-white — 4.6:1 on secondary */
|
||||
|
||||
--color-accent: oklch(75% 0.045 130); /* tart lime zest */
|
||||
--color-accent-content: oklch(98.5% 0.01 130); /* near-white — 4.8:1 on accent */
|
||||
--color-accent: oklch(75% 0.045 130); /* tart lime zest */
|
||||
--color-accent-content: oklch(98.5% 0.01 130); /* near-white — 4.8:1 on accent */
|
||||
|
||||
--color-neutral: oklch(18% 0.016 290); /* deep blackened grape */
|
||||
--color-neutral-content: oklch(88% 0.01 295); /* pale lavender — 9.3:1 on neutral */
|
||||
--color-neutral: oklch(18% 0.016 290); /* deep blackened grape */
|
||||
--color-neutral-content: oklch(88% 0.01 295); /* pale lavender — 9.3:1 on neutral */
|
||||
|
||||
--color-info: oklch(62% 0.04 250); /* muted indigo */
|
||||
--color-info-content: oklch(88% 0.008 250); /* pale indigo-white — 4.8:1 on info */
|
||||
--color-info: oklch(62% 0.04 250); /* muted indigo */
|
||||
--color-info-content: oklch(88% 0.008 250); /* pale indigo-white — 4.8:1 on info */
|
||||
|
||||
--color-success: oklch(65% 0.04 145); /* elderberry green */
|
||||
--color-success-content: oklch(90% 0.008 145); /* pale green-white — 4.7:1 on success */
|
||||
--color-success: oklch(65% 0.04 145); /* elderberry green */
|
||||
--color-success-content: oklch(90% 0.008 145); /* pale green-white — 4.7:1 on success */
|
||||
|
||||
--color-warning: oklch(70% 0.05 65); /* sour apricot */
|
||||
--color-warning-content: oklch(97% 0.03 65); /* near-white — 5.6:1 on warning */
|
||||
--color-warning: oklch(70% 0.05 65); /* sour apricot */
|
||||
--color-warning-content: oklch(97% 0.03 65); /* near-white — 5.6:1 on warning */
|
||||
|
||||
--color-error: oklch(50% 0.055 22); /* kriek red */
|
||||
--color-error-content: oklch(97% 0.006 22); /* near-white — 13.2:1 on error */
|
||||
--color-error: oklch(50% 0.055 22); /* kriek red */
|
||||
--color-error-content: oklch(97% 0.006 22); /* near-white — 13.2:1 on error */
|
||||
|
||||
--color-surface: oklch(27% 0.022 292); /* lifted purple-black panel */
|
||||
--color-surface-content: oklch(90% 0.014 300); /* pale lavender-white — 10.4:1 on surface */
|
||||
--color-surface: oklch(27% 0.022 292); /* lifted purple-black panel */
|
||||
--color-surface-content: oklch(90% 0.014 300); /* pale lavender-white — 10.4:1 on surface */
|
||||
|
||||
--color-muted: oklch(
|
||||
77.6% 0.022 300
|
||||
); /* desaturated lavender — 4.6:1 on base-100, 4.5:1 on surface */
|
||||
--color-highlight: oklch(35% 0.04 295); /* cassis-tinted hover and active state */
|
||||
--color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white — 10.1:1 on highlight */
|
||||
--color-muted: oklch(
|
||||
77.6% 0.022 300
|
||||
); /* desaturated lavender — 4.6:1 on base-100, 4.5:1 on surface */
|
||||
--color-highlight: oklch(35% 0.04 295); /* cassis-tinted hover and active state */
|
||||
--color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white — 10.1:1 on highlight */
|
||||
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 1;
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 1;
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────
|
||||
@@ -199,53 +199,53 @@ h6,
|
||||
fresh-cut barley primary, sage secondary.
|
||||
───────────────────────────────────────── */
|
||||
@plugin "daisyui/theme" {
|
||||
name: 'biergarten-weizen';
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
color-scheme: 'light';
|
||||
name: 'biergarten-weizen';
|
||||
default: false;
|
||||
prefersdark: false;
|
||||
color-scheme: 'light';
|
||||
|
||||
--color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */
|
||||
--color-base-200: oklch(96% 0.012 114); /* pale barley wash */
|
||||
--color-base-300: oklch(92% 0.019 116); /* light straw */
|
||||
--color-base-content: oklch(20% 0.022 122); /* deep green-black — 19.5:1 on base-100 */
|
||||
--color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */
|
||||
--color-base-200: oklch(96% 0.012 114); /* pale barley wash */
|
||||
--color-base-300: oklch(92% 0.019 116); /* light straw */
|
||||
--color-base-content: oklch(20% 0.022 122); /* deep green-black — 19.5:1 on base-100 */
|
||||
|
||||
--color-primary: oklch(52% 0.085 118); /* fresh-cut barley green */
|
||||
--color-primary-content: oklch(97% 0.005 118); /* near-white — 12.5:1 on primary */
|
||||
--color-primary: oklch(52% 0.085 118); /* fresh-cut barley green */
|
||||
--color-primary-content: oklch(97% 0.005 118); /* near-white — 12.5:1 on primary */
|
||||
|
||||
--color-secondary: oklch(44% 0.055 128); /* muted sage stem */
|
||||
--color-secondary-content: oklch(97% 0.005 128); /* near-white — 14.8:1 on secondary */
|
||||
--color-secondary: oklch(44% 0.055 128); /* muted sage stem */
|
||||
--color-secondary-content: oklch(97% 0.005 128); /* near-white — 14.8:1 on secondary */
|
||||
|
||||
--color-accent: oklch(93% 0.03 148); /* pale morning dew */
|
||||
--color-accent-content: oklch(22% 0.022 148); /* deep green — 13.4:1 on accent */
|
||||
--color-accent: oklch(93% 0.03 148); /* pale morning dew */
|
||||
--color-accent-content: oklch(22% 0.022 148); /* deep green — 13.4:1 on accent */
|
||||
|
||||
--color-neutral: oklch(76% 0.028 118); /* dried straw, surface differentiation */
|
||||
--color-neutral-content: oklch(98.9% 0.005 118); /* near-white — 4.6:1 on neutral */
|
||||
--color-neutral: oklch(76% 0.028 118); /* dried straw, surface differentiation */
|
||||
--color-neutral-content: oklch(98.9% 0.005 118); /* near-white — 4.6:1 on neutral */
|
||||
|
||||
--color-info: oklch(38% 0.065 232); /* clear summer sky */
|
||||
--color-info-content: oklch(98% 0.005 232); /* near-white — 16.8:1 on info */
|
||||
--color-info: oklch(38% 0.065 232); /* clear summer sky */
|
||||
--color-info-content: oklch(98% 0.005 232); /* near-white — 16.8:1 on info */
|
||||
|
||||
--color-success: oklch(38% 0.085 145); /* young shoot green */
|
||||
--color-success-content: oklch(98% 0.005 145); /* near-white — 16.8:1 on success */
|
||||
--color-success: oklch(38% 0.085 145); /* young shoot green */
|
||||
--color-success-content: oklch(98% 0.005 145); /* near-white — 16.8:1 on success */
|
||||
|
||||
--color-warning: oklch(68% 0.1 76); /* ripening grain amber */
|
||||
--color-warning-content: oklch(92.5% 0.005 72); /* warm near-white — 4.5:1 on warning */
|
||||
--color-warning: oklch(68% 0.1 76); /* ripening grain amber */
|
||||
--color-warning-content: oklch(92.5% 0.005 72); /* warm near-white — 4.5:1 on warning */
|
||||
|
||||
--color-error: oklch(52% 0.1 18); /* dusty rose red */
|
||||
--color-error-content: oklch(98% 0.005 15); /* near-white — 12.5:1 on error */
|
||||
--color-error: oklch(52% 0.1 18); /* dusty rose red */
|
||||
--color-error-content: oklch(98% 0.005 15); /* near-white — 12.5:1 on error */
|
||||
|
||||
--color-surface: oklch(94% 0.012 112); /* soft barley-wash panel */
|
||||
--color-surface-content: oklch(20% 0.022 122); /* deep green-black — 14.0:1 on surface */
|
||||
--color-surface: oklch(94% 0.012 112); /* soft barley-wash panel */
|
||||
--color-surface-content: oklch(20% 0.022 122); /* deep green-black — 14.0:1 on surface */
|
||||
|
||||
--color-muted: oklch(38% 0.055 120); /* sage green — 18.1:1 on base-100, 13.0:1 on surface */
|
||||
--color-highlight: oklch(85% 0.04 118); /* green-tinted hover and active state */
|
||||
--color-highlight-content: oklch(20% 0.022 122); /* deep green-black — 7.8:1 on highlight */
|
||||
--color-muted: oklch(38% 0.055 120); /* sage green — 18.1:1 on base-100, 13.0:1 on surface */
|
||||
--color-highlight: oklch(85% 0.04 118); /* green-tinted hover and active state */
|
||||
--color-highlight-content: oklch(20% 0.022 122); /* deep green-black — 7.8:1 on highlight */
|
||||
|
||||
--radius-selector: 2rem;
|
||||
--radius-field: 2rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
--radius-selector: 2rem;
|
||||
--radius-field: 2rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuItems,
|
||||
} from '@headlessui/react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface NavbarProps {
|
||||
auth: {
|
||||
username: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userAccountId: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function Navbar({ auth }: NavbarProps) {
|
||||
const navLinks = [
|
||||
{ to: '/theme', label: 'Theme' },
|
||||
{ to: '/beers', label: 'Beers' },
|
||||
{ to: '/breweries', label: 'Breweries' },
|
||||
{ to: '/beer-styles', label: 'Beer Styles' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Disclosure
|
||||
as="nav"
|
||||
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="navbar mx-auto max-w-7xl px-2 sm:px-4">
|
||||
<div className="navbar-start gap-2">
|
||||
<DisclosureButton
|
||||
className="btn btn-ghost btn-square lg:hidden"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5 stroke-current"
|
||||
>
|
||||
{open ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</DisclosureButton>
|
||||
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
🍺 The Biergarten App
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="navbar-center hidden lg:flex gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link key={link.to} to={link.to} className="btn btn-ghost btn-sm">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="navbar-end gap-2">
|
||||
{!auth && (
|
||||
<Link to="/register" className="btn btn-ghost btn-sm hidden sm:inline-flex">
|
||||
Register User
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{auth ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="btn btn-primary btn-sm">
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="btn btn-ghost btn-sm">
|
||||
{auth.username}
|
||||
</MenuButton>
|
||||
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none">
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/dashboard" className={focus ? 'active' : ''}>
|
||||
Dashboard
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/logout" className={focus ? 'active' : ''}>
|
||||
Logout
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="btn btn-primary btn-sm">
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
|
||||
<div className="flex flex-col gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="btn btn-ghost btn-sm justify-start"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
{!auth && (
|
||||
<Link to="/register" className="btn btn-ghost btn-sm justify-start">
|
||||
Register User
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Description, Field, Label } from '@headlessui/react';
|
||||
|
||||
type FormFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
labelClassName?: string;
|
||||
inputClassName?: string;
|
||||
hintClassName?: string;
|
||||
};
|
||||
|
||||
export default function FormField({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
className,
|
||||
labelClassName,
|
||||
inputClassName,
|
||||
hintClassName,
|
||||
...inputProps
|
||||
}: FormFieldProps) {
|
||||
return (
|
||||
<Field className={className ?? 'space-y-1'}>
|
||||
<Label htmlFor={inputProps.id} className={labelClassName ?? 'label font-medium'}>
|
||||
{label}
|
||||
</Label>
|
||||
|
||||
<input
|
||||
{...inputProps}
|
||||
className={inputClassName ?? `input w-full ${error ? 'input-error' : ''}`}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Description className={hintClassName ?? 'label text-error'}>{error}</Description>
|
||||
) : hint ? (
|
||||
<Description className={hintClassName ?? 'label'}>{hint}</Description>
|
||||
) : null}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Button } from '@headlessui/react';
|
||||
|
||||
interface SubmitButtonProps {
|
||||
isSubmitting: boolean;
|
||||
idleText: string;
|
||||
submittingText: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SubmitButton({
|
||||
isSubmitting,
|
||||
idleText,
|
||||
submittingText,
|
||||
className,
|
||||
}: SubmitButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={className ?? 'btn btn-primary w-full mt-2'}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-sm" /> {submittingText}
|
||||
</>
|
||||
) : (
|
||||
idleText
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
export default function ToastProvider() {
|
||||
return (
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 3500,
|
||||
className: 'rounded-box border border-base-300 bg-base-100 text-base-content shadow-lg',
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: 'var(--color-success)',
|
||||
secondary: 'var(--color-success-content)',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: 'var(--color-error)',
|
||||
secondary: 'var(--color-error-content)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
web/frontend/app/components/ui/forms/FormField.tsx
Normal file
42
web/frontend/app/components/ui/forms/FormField.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Description, Field, Label } from '@headlessui/react';
|
||||
|
||||
type FormFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
labelClassName?: string;
|
||||
inputClassName?: string;
|
||||
hintClassName?: string;
|
||||
};
|
||||
|
||||
const FormField = ({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
className,
|
||||
labelClassName,
|
||||
inputClassName,
|
||||
hintClassName,
|
||||
...inputProps
|
||||
}: FormFieldProps) => {
|
||||
return (
|
||||
<Field className={className ?? 'space-y-1'}>
|
||||
<Label htmlFor={inputProps.id} className={labelClassName ?? 'label font-medium'}>
|
||||
{label}
|
||||
</Label>
|
||||
|
||||
<input
|
||||
{...inputProps}
|
||||
className={inputClassName ?? `input w-full ${error ? 'input-error' : ''}`}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Description className={hintClassName ?? 'label text-error'}>{error}</Description>
|
||||
) : hint ? (
|
||||
<Description className={hintClassName ?? 'label'}>{hint}</Description>
|
||||
) : null}
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormField;
|
||||
28
web/frontend/app/components/ui/forms/SubmitButton.tsx
Normal file
28
web/frontend/app/components/ui/forms/SubmitButton.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Button } from '@headlessui/react';
|
||||
|
||||
interface SubmitButtonProps {
|
||||
isSubmitting: boolean;
|
||||
idleText: string;
|
||||
submittingText: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SubmitButton = ({ isSubmitting, idleText, submittingText, className }: SubmitButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={className ?? 'btn btn-primary w-full mt-2'}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-sm" /> {submittingText}
|
||||
</>
|
||||
) : (
|
||||
idleText
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubmitButton;
|
||||
44
web/frontend/app/components/ui/navbar/NavLinks.tsx
Normal file
44
web/frontend/app/components/ui/navbar/NavLinks.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/theme', label: 'Theme' },
|
||||
{ to: '/beers', label: 'Beers' },
|
||||
{ to: '/breweries', label: 'Breweries' },
|
||||
{ to: '/beer-styles', label: 'Beer Styles' },
|
||||
];
|
||||
|
||||
interface NavLinksProps {
|
||||
variant: 'desktop' | 'mobile';
|
||||
showRegister: boolean;
|
||||
}
|
||||
|
||||
const NavLinks = ({ variant, showRegister }: NavLinksProps) => {
|
||||
if (variant === 'desktop') {
|
||||
return (
|
||||
<div className="navbar-center hidden lg:flex gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link key={link.to} to={link.to} className="btn btn-ghost btn-sm">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link key={link.to} to={link.to} className="btn btn-ghost btn-sm justify-start">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
{showRegister && (
|
||||
<Link to="/register" className="btn btn-ghost btn-sm justify-start">
|
||||
Register User
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavLinks;
|
||||
93
web/frontend/app/components/ui/navbar/Navbar.tsx
Normal file
93
web/frontend/app/components/ui/navbar/Navbar.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react';
|
||||
import { Link } from 'react-router';
|
||||
import NavLinks from './NavLinks';
|
||||
import UserMenu from './UserMenu';
|
||||
|
||||
interface NavbarProps {
|
||||
auth: {
|
||||
username: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userAccountId: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const Navbar = ({ auth }: NavbarProps) => (
|
||||
<Disclosure
|
||||
as="nav"
|
||||
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="navbar mx-auto max-w-7xl px-2 sm:px-4">
|
||||
<div className="navbar-start gap-2">
|
||||
<DisclosureButton
|
||||
className="btn btn-ghost btn-square lg:hidden"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5 stroke-current"
|
||||
>
|
||||
{open ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</DisclosureButton>
|
||||
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
🍺 The Biergarten App
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<NavLinks variant="desktop" showRegister={!auth} />
|
||||
|
||||
<div className="navbar-end gap-2">
|
||||
{!auth && (
|
||||
<Link
|
||||
to="/register"
|
||||
className="btn btn-ghost btn-sm hidden sm:inline-flex"
|
||||
>
|
||||
Register User
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{auth ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="btn btn-primary btn-sm">
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<UserMenu username={auth.username} />
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="btn btn-primary btn-sm">
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
|
||||
<NavLinks variant="mobile" showRegister={!auth} />
|
||||
</DisclosurePanel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
|
||||
export default Navbar;
|
||||
30
web/frontend/app/components/ui/navbar/UserMenu.tsx
Normal file
30
web/frontend/app/components/ui/navbar/UserMenu.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface UserMenuProps {
|
||||
username: string;
|
||||
}
|
||||
|
||||
const UserMenu = ({ username }: UserMenuProps) => (
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="btn btn-ghost btn-sm">{username}</MenuButton>
|
||||
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none">
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/dashboard" className={focus ? 'active' : ''}>
|
||||
Dashboard
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/logout" className={focus ? 'active' : ''}>
|
||||
Logout
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
export default UserMenu;
|
||||
28
web/frontend/app/components/ui/toast/ToastProvider.tsx
Normal file
28
web/frontend/app/components/ui/toast/ToastProvider.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
const ToastProvider = () => {
|
||||
return (
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 3500,
|
||||
className:
|
||||
'rounded-box border border-base-300 bg-base-100 text-base-content shadow-lg',
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: 'var(--color-success)',
|
||||
secondary: 'var(--color-success-content)',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: 'var(--color-error)',
|
||||
secondary: 'var(--color-error-content)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastProvider;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const AuthFlowDemoCard = () => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">Auth Flow Demo</h2>
|
||||
<p className="text-sm text-base-content/70">
|
||||
This demo showcases the following authentication features:
|
||||
</p>
|
||||
<ul className="list">
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Login</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
|
||||
username & password
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Register</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/register</code> with full
|
||||
user details
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Session</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
JWT access & refresh tokens stored in an HTTP-only cookie
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Protected Routes</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
This dashboard requires authentication via{' '}
|
||||
<code className="kbd kbd-sm">requireAuth()</code>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Token Refresh</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with the
|
||||
refresh token in an <code className="kbd kbd-sm">X-Refresh-Token</code>{' '}
|
||||
header
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Account Management</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
PATCH <code className="kbd kbd-sm">/api/auth/username</code>,{' '}
|
||||
<code className="kbd kbd-sm">/email</code>,{' '}
|
||||
<code className="kbd kbd-sm">/password</code>,{' '}
|
||||
<code className="kbd kbd-sm">/profile</code>, and DELETE{' '}
|
||||
<code className="kbd kbd-sm">/account</code> — see{' '}
|
||||
<Link to="/account" className="link">
|
||||
Account Settings
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AuthFlowDemoCard;
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react';
|
||||
import { Trash, WarningTriangle } from 'iconoir-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigation, useSubmit } from 'react-router';
|
||||
import { useSectionError } from '../hooks/useSectionError';
|
||||
import type { ActionResult } from '../types';
|
||||
|
||||
interface DeleteAccountSectionProps {
|
||||
userAccountId: string;
|
||||
result: ActionResult | undefined;
|
||||
}
|
||||
|
||||
const DeleteAccountSection = ({ userAccountId, result }: DeleteAccountSectionProps) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const isSubmitting = navigation.formData?.get('intent') === 'delete';
|
||||
|
||||
useSectionError(result, 'delete');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card bg-error/5 border border-error/30 shadow">
|
||||
<div className="card-body">
|
||||
<div className="flex items-center justify-between gap-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-error mt-1">
|
||||
<Trash className="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="card-title text-lg">Delete Account</h2>
|
||||
<p className="text-sm text-base-content/70">
|
||||
Permanently delete your account. This cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-error btn-outline btn-sm"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
className="relative z-50"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/40" aria-hidden="true" />
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<DialogPanel className="card w-full max-w-sm bg-base-100 shadow-xl">
|
||||
<div className="card-body items-center text-center gap-3">
|
||||
<WarningTriangle className="size-10 text-error" aria-hidden="true" />
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
Delete your account?
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-base-content/70">
|
||||
This action is permanent and cannot be reversed. Account ID{' '}
|
||||
<span className="font-mono text-xs">{userAccountId}</span> and all
|
||||
associated data will be removed.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 w-full pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-error btn-sm w-full"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => submit({ intent: 'delete' }, { method: 'post' })}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-sm" />{' '}
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
'Yes, delete my account'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm w-full"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteAccountSection;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Mail } from 'iconoir-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import { useSectionError } from '../hooks/useSectionError';
|
||||
import { updateEmailSchema, type UpdateEmailSchema } from '../schemas';
|
||||
import type { SectionProps } from '../types';
|
||||
import SectionCard from './SectionCard';
|
||||
|
||||
const EmailSection = ({ email, open, onToggle, result }: SectionProps & { email: string }) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.formData?.get('intent') === 'email';
|
||||
|
||||
const emailForm = useForm<UpdateEmailSchema>({
|
||||
resolver: zodResolver(updateEmailSchema),
|
||||
defaultValues: { newEmail: email },
|
||||
});
|
||||
|
||||
useSectionError(result, 'email');
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={<Mail className="size-5" aria-hidden="true" />}
|
||||
title="Email Address"
|
||||
description="Change your email. You'll need to re-confirm the new address."
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={emailForm.handleSubmit((values) =>
|
||||
submit({ ...values, intent: 'email' }, { method: 'post' }),
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
id="newEmail"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="jane@example.com"
|
||||
label="New Email"
|
||||
error={emailForm.formState.errors.newEmail?.message}
|
||||
{...emailForm.register('newEmail')}
|
||||
/>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Update Email"
|
||||
submittingText="Updating..."
|
||||
/>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailSection;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Lock } from 'iconoir-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import { useSectionError } from '../hooks/useSectionError';
|
||||
import { updatePasswordSchema, type UpdatePasswordSchema } from '../schemas';
|
||||
import type { SectionProps } from '../types';
|
||||
import SectionCard from './SectionCard';
|
||||
|
||||
const PasswordSection = ({ open, onToggle, result }: SectionProps) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.formData?.get('intent') === 'password';
|
||||
|
||||
const passwordForm = useForm<UpdatePasswordSchema>({
|
||||
resolver: zodResolver(updatePasswordSchema),
|
||||
});
|
||||
|
||||
useSectionError(result, 'password');
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={<Lock className="size-5" aria-hidden="true" />}
|
||||
title="Password"
|
||||
description="Change the password used to sign in."
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={passwordForm.handleSubmit((values) =>
|
||||
submit({ ...values, intent: 'password' }, { method: 'post' }),
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
label="Current Password"
|
||||
error={passwordForm.formState.errors.currentPassword?.message}
|
||||
{...passwordForm.register('currentPassword')}
|
||||
/>
|
||||
<FormField
|
||||
id="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="New Password"
|
||||
hint="8+ chars: uppercase, lowercase, digit, special character"
|
||||
error={passwordForm.formState.errors.newPassword?.message}
|
||||
{...passwordForm.register('newPassword')}
|
||||
/>
|
||||
<FormField
|
||||
id="confirmNewPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="Confirm New Password"
|
||||
error={passwordForm.formState.errors.confirmNewPassword?.message}
|
||||
{...passwordForm.register('confirmNewPassword')}
|
||||
/>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Change Password"
|
||||
submittingText="Changing..."
|
||||
/>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordSection;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { User } from 'iconoir-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import { useSectionError } from '../hooks/useSectionError';
|
||||
import { updateProfileSchema, type UpdateProfileSchema } from '../schemas';
|
||||
import type { SectionProps } from '../types';
|
||||
import SectionCard from './SectionCard';
|
||||
|
||||
const ProfileSection = ({
|
||||
firstName,
|
||||
lastName,
|
||||
dateOfBirth,
|
||||
open,
|
||||
onToggle,
|
||||
result,
|
||||
}: SectionProps & { firstName: string; lastName: string; dateOfBirth: string }) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.formData?.get('intent') === 'profile';
|
||||
|
||||
const profileForm = useForm<UpdateProfileSchema>({
|
||||
resolver: zodResolver(updateProfileSchema),
|
||||
defaultValues: { firstName, lastName, dateOfBirth },
|
||||
});
|
||||
|
||||
useSectionError(result, 'profile');
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={<User className="size-5" aria-hidden="true" />}
|
||||
title="Profile"
|
||||
description="Update your name and date of birth."
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={profileForm.handleSubmit((values) =>
|
||||
submit({ ...values, intent: 'profile' }, { method: 'post' }),
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
id="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="Jane"
|
||||
label="First Name"
|
||||
error={profileForm.formState.errors.firstName?.message}
|
||||
{...profileForm.register('firstName')}
|
||||
/>
|
||||
<FormField
|
||||
id="lastName"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Doe"
|
||||
label="Last Name"
|
||||
error={profileForm.formState.errors.lastName?.message}
|
||||
{...profileForm.register('lastName')}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
id="dateOfBirth"
|
||||
type="date"
|
||||
label="Date of Birth"
|
||||
error={profileForm.formState.errors.dateOfBirth?.message}
|
||||
{...profileForm.register('dateOfBirth')}
|
||||
/>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Update Profile"
|
||||
submittingText="Updating..."
|
||||
/>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSection;
|
||||
36
web/frontend/app/features/account/components/SectionCard.tsx
Normal file
36
web/frontend/app/features/account/components/SectionCard.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface SectionCardProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const SectionCard = ({ icon, title, description, open, onToggle, children }: SectionCardProps) => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<div className="flex w-full items-center justify-between gap-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-base-content/60 mt-1">{icon}</span>
|
||||
<div>
|
||||
<h2 className="card-title text-lg">{title}</h2>
|
||||
<p className="text-sm text-base-content/70">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle toggle-primary"
|
||||
checked={open}
|
||||
onChange={onToggle}
|
||||
aria-label={`Toggle ${title.toLowerCase()} form`}
|
||||
/>
|
||||
</div>
|
||||
{open && <div className="mt-4">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SectionCard;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface SessionInfoCardProps {
|
||||
username: string;
|
||||
userAccountId: string;
|
||||
}
|
||||
|
||||
const SessionInfoCard = ({ username, userAccountId }: SessionInfoCardProps) => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-2xl">Welcome, {username}!</h2>
|
||||
<p className="text-base-content/70">
|
||||
You are successfully authenticated. This is a protected page that requires a valid
|
||||
session.
|
||||
</p>
|
||||
|
||||
<div className="bg-base-200 rounded-box p-4 mt-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
|
||||
Session Info
|
||||
</p>
|
||||
<div className="stats stats-vertical w-full">
|
||||
<div className="stat py-2">
|
||||
<div className="stat-title">Username</div>
|
||||
<div className="stat-value text-lg font-mono">{username}</div>
|
||||
</div>
|
||||
<div className="stat py-2">
|
||||
<div className="stat-title">User ID</div>
|
||||
<div className="stat-desc font-mono text-xs mt-1">{userAccountId}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-actions mt-4">
|
||||
<Link to="/account" className="btn btn-outline btn-sm">
|
||||
Account Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SessionInfoCard;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { User } from 'iconoir-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import { useSectionError } from '../hooks/useSectionError';
|
||||
import { updateUsernameSchema, type UpdateUsernameSchema } from '../schemas';
|
||||
import type { SectionProps } from '../types';
|
||||
import SectionCard from './SectionCard';
|
||||
|
||||
const UsernameSection = ({
|
||||
username,
|
||||
open,
|
||||
onToggle,
|
||||
result,
|
||||
}: SectionProps & { username: string }) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.formData?.get('intent') === 'username';
|
||||
|
||||
const usernameForm = useForm<UpdateUsernameSchema>({
|
||||
resolver: zodResolver(updateUsernameSchema),
|
||||
defaultValues: { newUsername: username },
|
||||
});
|
||||
|
||||
useSectionError(result, 'username');
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={<User className="size-5" aria-hidden="true" />}
|
||||
title="Username"
|
||||
description="Change the username you sign in with."
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={usernameForm.handleSubmit((values) =>
|
||||
submit({ ...values, intent: 'username' }, { method: 'post' }),
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
id="newUsername"
|
||||
type="text"
|
||||
placeholder={username}
|
||||
label="New Username"
|
||||
error={usernameForm.formState.errors.newUsername?.message}
|
||||
{...usernameForm.register('newUsername')}
|
||||
/>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Update Username"
|
||||
submittingText="Updating..."
|
||||
/>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsernameSection;
|
||||
10
web/frontend/app/features/account/hooks/useSectionError.ts
Normal file
10
web/frontend/app/features/account/hooks/useSectionError.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import { showErrorToast } from '../../../components/ui/toast/toast';
|
||||
import type { ActionResult } from '../types';
|
||||
|
||||
export const useSectionError = (result: ActionResult | undefined, intent: ActionResult['intent']) => {
|
||||
useEffect(() => {
|
||||
if (!result || result.intent !== intent) return;
|
||||
showErrorToast(result.error);
|
||||
}, [result, intent]);
|
||||
};
|
||||
269
web/frontend/app/features/account/routes/account.tsx
Normal file
269
web/frontend/app/features/account/routes/account.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { Settings } from 'iconoir-react';
|
||||
import { useEffect, useReducer, useState } from 'react';
|
||||
import { data, Link, redirect, useSearchParams } from 'react-router';
|
||||
import { showSuccessToast } from '../../../components/ui/toast/toast';
|
||||
import {
|
||||
commitSession,
|
||||
deleteAccount,
|
||||
destroySession,
|
||||
getSession,
|
||||
getUserAccount,
|
||||
requireAuth,
|
||||
updateEmail,
|
||||
updatePassword,
|
||||
updateProfile,
|
||||
updateUsername,
|
||||
} from '../../auth/auth.server';
|
||||
import DeleteAccountSection from '../components/DeleteAccountSection';
|
||||
import EmailSection from '../components/EmailSection';
|
||||
import PasswordSection from '../components/PasswordSection';
|
||||
import ProfileSection from '../components/ProfileSection';
|
||||
import UsernameSection from '../components/UsernameSection';
|
||||
import {
|
||||
updateEmailSchema,
|
||||
updatePasswordSchema,
|
||||
updateProfileSchema,
|
||||
updateUsernameSchema,
|
||||
} from '../schemas';
|
||||
import { initialSectionState, sectionReducer } from '../state';
|
||||
import type { ActionResult } from '../types';
|
||||
|
||||
import type { Route } from './+types/account';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => [{ title: 'Account Settings | The Biergarten App' }];
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await requireAuth(request);
|
||||
|
||||
let details = { firstName: '', lastName: '', email: '', dateOfBirth: '' };
|
||||
try {
|
||||
const account = await getUserAccount(auth.userAccountId);
|
||||
details = {
|
||||
firstName: account.firstName,
|
||||
lastName: account.lastName,
|
||||
email: account.email,
|
||||
// Trim the backend's ISO datetime down to the yyyy-MM-dd a date input expects.
|
||||
dateOfBirth: account.dateOfBirth.slice(0, 10),
|
||||
};
|
||||
} catch {
|
||||
// Fall through with blank fields; the update forms still work standalone.
|
||||
}
|
||||
|
||||
return { username: auth.username, userAccountId: auth.userAccountId, ...details };
|
||||
};
|
||||
|
||||
const redirectWithToast = (message: string, headers?: HeadersInit) =>
|
||||
redirect(`/account?toast=${encodeURIComponent(message)}`, headers ? { headers } : undefined);
|
||||
|
||||
export const action = async ({ request }: Route.ActionArgs) => {
|
||||
const auth = await requireAuth(request);
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get('intent');
|
||||
|
||||
switch (intent) {
|
||||
case 'delete': {
|
||||
try {
|
||||
await deleteAccount(auth.accessToken);
|
||||
} catch (err) {
|
||||
return {
|
||||
intent: 'delete' as const,
|
||||
error: err instanceof Error ? err.message : 'Failed to delete account.',
|
||||
};
|
||||
}
|
||||
const session = await getSession(request);
|
||||
return redirect('/', { headers: { 'Set-Cookie': await destroySession(session) } });
|
||||
}
|
||||
|
||||
case 'username': {
|
||||
const result = updateUsernameSchema.safeParse({
|
||||
newUsername: formData.get('newUsername'),
|
||||
});
|
||||
if (!result.success) return { intent, error: result.error.issues[0].message } as const;
|
||||
|
||||
try {
|
||||
const payload = await updateUsername(auth.accessToken, result.data.newUsername);
|
||||
const session = await getSession(request);
|
||||
session.set('username', payload.username);
|
||||
return redirectWithToast('Username updated successfully.', {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
intent,
|
||||
error: err instanceof Error ? err.message : 'Failed to update username.',
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
case 'email': {
|
||||
const result = updateEmailSchema.safeParse({ newEmail: formData.get('newEmail') });
|
||||
if (!result.success) return { intent, error: result.error.issues[0].message } as const;
|
||||
|
||||
try {
|
||||
const payload = await updateEmail(auth.accessToken, result.data.newEmail);
|
||||
return redirectWithToast(
|
||||
payload.emailConfirmed
|
||||
? 'Email updated successfully.'
|
||||
: 'Email updated. Please re-confirm your new address.',
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
intent,
|
||||
error: err instanceof Error ? err.message : 'Failed to update email.',
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
case 'profile': {
|
||||
const result = updateProfileSchema.safeParse({
|
||||
firstName: formData.get('firstName'),
|
||||
lastName: formData.get('lastName'),
|
||||
dateOfBirth: formData.get('dateOfBirth'),
|
||||
});
|
||||
if (!result.success) return { intent, error: result.error.issues[0].message } as const;
|
||||
|
||||
try {
|
||||
await updateProfile(
|
||||
auth.accessToken,
|
||||
result.data.firstName,
|
||||
result.data.lastName,
|
||||
result.data.dateOfBirth,
|
||||
);
|
||||
return redirectWithToast('Profile updated successfully.');
|
||||
} catch (err) {
|
||||
return {
|
||||
intent,
|
||||
error: err instanceof Error ? err.message : 'Failed to update profile.',
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
case 'password': {
|
||||
const result = updatePasswordSchema.safeParse({
|
||||
currentPassword: formData.get('currentPassword'),
|
||||
newPassword: formData.get('newPassword'),
|
||||
confirmNewPassword: formData.get('confirmNewPassword'),
|
||||
});
|
||||
if (!result.success) return { intent, error: result.error.issues[0].message } as const;
|
||||
|
||||
try {
|
||||
await updatePassword(
|
||||
auth.accessToken,
|
||||
result.data.currentPassword,
|
||||
result.data.newPassword,
|
||||
);
|
||||
return redirectWithToast('Password changed successfully.');
|
||||
} catch (err) {
|
||||
return {
|
||||
intent,
|
||||
error: err instanceof Error ? err.message : 'Failed to update password.',
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
throw data('Unknown request.', { status: 400 });
|
||||
}
|
||||
};
|
||||
|
||||
const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
|
||||
const { username, userAccountId, firstName, lastName, email, dateOfBirth } = loaderData;
|
||||
const result = actionData as ActionResult | undefined;
|
||||
|
||||
const [sections, dispatch] = useReducer(sectionReducer, initialSectionState);
|
||||
// Bumped once per successful update so the section subtree below remounts
|
||||
// with fresh `key`s, picking up the new loader values (and, for
|
||||
// PasswordSection, clearing fields that have no persisted value to key
|
||||
// off) without needing an effect to imperatively reset each form.
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const toast = searchParams.get('toast');
|
||||
|
||||
// Collapsing the sections and bumping resetKey are state adjustments in
|
||||
// response to a changed prop (the `toast` search param), so they happen
|
||||
// directly during render - React's documented alternative to an effect
|
||||
// for this case - guarded by `handledToast` so it only runs once per
|
||||
// redirect. Showing the toast and clearing the param are genuine side
|
||||
// effects on an external system (react-hot-toast, the URL) and stay in
|
||||
// the effect below.
|
||||
const [handledToast, setHandledToast] = useState<string | null>(null);
|
||||
if (toast && toast !== handledToast) {
|
||||
setHandledToast(toast);
|
||||
dispatch({ type: 'CLOSE_ALL' });
|
||||
setResetKey((key) => key + 1);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
showSuccessToast(toast);
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('toast');
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [toast, setSearchParams]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="mx-auto max-w-2xl px-6 py-10 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="size-7" aria-hidden="true" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Account Settings</h1>
|
||||
<p className="text-base-content/70">
|
||||
Manage the account details for{' '}
|
||||
<span className="font-mono">{username}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UsernameSection
|
||||
key={`username-${resetKey}`}
|
||||
username={username}
|
||||
open={sections.usernameOpen}
|
||||
onToggle={() => dispatch({ type: 'TOGGLE_USERNAME_VISIBILITY' })}
|
||||
result={result}
|
||||
/>
|
||||
|
||||
<EmailSection
|
||||
key={`email-${resetKey}`}
|
||||
email={email}
|
||||
open={sections.emailOpen}
|
||||
onToggle={() => dispatch({ type: 'TOGGLE_EMAIL_VISIBILITY' })}
|
||||
result={result}
|
||||
/>
|
||||
|
||||
<ProfileSection
|
||||
key={`profile-${resetKey}`}
|
||||
firstName={firstName}
|
||||
lastName={lastName}
|
||||
dateOfBirth={dateOfBirth}
|
||||
open={sections.profileOpen}
|
||||
onToggle={() => dispatch({ type: 'TOGGLE_PROFILE_VISIBILITY' })}
|
||||
result={result}
|
||||
/>
|
||||
|
||||
<PasswordSection
|
||||
key={`password-${resetKey}`}
|
||||
open={sections.passwordOpen}
|
||||
onToggle={() => dispatch({ type: 'TOGGLE_PASSWORD_VISIBILITY' })}
|
||||
result={result}
|
||||
/>
|
||||
|
||||
<DeleteAccountSection userAccountId={userAccountId} result={result} />
|
||||
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="link link-hover text-sm text-base-content/60 inline-block"
|
||||
>
|
||||
← Back to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountPage;
|
||||
31
web/frontend/app/features/account/routes/dashboard.tsx
Normal file
31
web/frontend/app/features/account/routes/dashboard.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { requireAuth } from '../../auth/auth.server';
|
||||
import AuthFlowDemoCard from '../components/AuthFlowDemoCard';
|
||||
import SessionInfoCard from '../components/SessionInfoCard';
|
||||
import type { Route } from './+types/dashboard';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [{ title: 'Dashboard | The Biergarten App' }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await requireAuth(request);
|
||||
return {
|
||||
username: auth.username,
|
||||
userAccountId: auth.userAccountId,
|
||||
};
|
||||
};
|
||||
|
||||
const Dashboard = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { username, userAccountId } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
|
||||
<SessionInfoCard username={username} userAccountId={userAccountId} />
|
||||
<AuthFlowDemoCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
48
web/frontend/app/features/account/schemas.ts
Normal file
48
web/frontend/app/features/account/schemas.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const updateUsernameSchema = z.object({
|
||||
newUsername: z
|
||||
.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(64, 'Username must be at most 64 characters')
|
||||
.regex(/^[a-zA-Z0-9._-]+$/, 'Only letters, numbers, dots, underscores, and hyphens'),
|
||||
});
|
||||
|
||||
export type UpdateUsernameSchema = z.infer<typeof updateUsernameSchema>;
|
||||
|
||||
export const updateEmailSchema = z.object({
|
||||
newEmail: z.string().email('Invalid email address').max(128, 'Email is too long'),
|
||||
});
|
||||
|
||||
export type UpdateEmailSchema = z.infer<typeof updateEmailSchema>;
|
||||
|
||||
export const updateProfileSchema = z.object({
|
||||
firstName: z.string().min(1, 'First name is required').max(128, 'First name is too long'),
|
||||
lastName: z.string().min(1, 'Last name is required').max(128, 'Last name is too long'),
|
||||
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||
});
|
||||
|
||||
export type UpdateProfileSchema = z.infer<typeof updateProfileSchema>;
|
||||
|
||||
export const updatePasswordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, 'Current password is required'),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain a lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain a number')
|
||||
.regex(/[^a-zA-Z0-9]/, 'Password must contain a special character'),
|
||||
confirmNewPassword: z.string().min(1, 'Please confirm your new password'),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmNewPassword, {
|
||||
message: 'Passwords must match',
|
||||
path: ['confirmNewPassword'],
|
||||
})
|
||||
.refine((data) => data.newPassword !== data.currentPassword, {
|
||||
message: 'New password must be different from the current password',
|
||||
path: ['newPassword'],
|
||||
});
|
||||
|
||||
export type UpdatePasswordSchema = z.infer<typeof updatePasswordSchema>;
|
||||
25
web/frontend/app/features/account/state.ts
Normal file
25
web/frontend/app/features/account/state.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { SectionAction, SectionState } from './types';
|
||||
|
||||
export const initialSectionState: SectionState = {
|
||||
usernameOpen: false,
|
||||
emailOpen: false,
|
||||
profileOpen: false,
|
||||
passwordOpen: false,
|
||||
};
|
||||
|
||||
export const sectionReducer = (state: SectionState, action: SectionAction): SectionState => {
|
||||
switch (action.type) {
|
||||
case 'TOGGLE_USERNAME_VISIBILITY':
|
||||
return { ...initialSectionState, usernameOpen: !state.usernameOpen };
|
||||
case 'TOGGLE_EMAIL_VISIBILITY':
|
||||
return { ...initialSectionState, emailOpen: !state.emailOpen };
|
||||
case 'TOGGLE_PROFILE_VISIBILITY':
|
||||
return { ...initialSectionState, profileOpen: !state.profileOpen };
|
||||
case 'TOGGLE_PASSWORD_VISIBILITY':
|
||||
return { ...initialSectionState, passwordOpen: !state.passwordOpen };
|
||||
case 'CLOSE_ALL':
|
||||
return initialSectionState;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
31
web/frontend/app/features/account/types.ts
Normal file
31
web/frontend/app/features/account/types.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Success never reaches the component as action data - every intent redirects
|
||||
// back to this route (with a `?toast=` message) instead of returning a
|
||||
// payload, so the only shape an action can hand back here is an error.
|
||||
export interface ActionResult {
|
||||
intent: 'username' | 'email' | 'profile' | 'password' | 'delete';
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface SectionState {
|
||||
usernameOpen: boolean;
|
||||
emailOpen: boolean;
|
||||
profileOpen: boolean;
|
||||
passwordOpen: boolean;
|
||||
}
|
||||
|
||||
export type ActionType =
|
||||
| 'TOGGLE_USERNAME_VISIBILITY'
|
||||
| 'TOGGLE_EMAIL_VISIBILITY'
|
||||
| 'TOGGLE_PROFILE_VISIBILITY'
|
||||
| 'TOGGLE_PASSWORD_VISIBILITY'
|
||||
| 'CLOSE_ALL';
|
||||
|
||||
export interface SectionAction {
|
||||
type: ActionType;
|
||||
}
|
||||
|
||||
export interface SectionProps {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
result: ActionResult | undefined;
|
||||
}
|
||||
267
web/frontend/app/features/auth/auth.server.ts
Normal file
267
web/frontend/app/features/auth/auth.server.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { createCookieSessionStorage, redirect } from 'react-router';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
message: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
interface LoginPayload {
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
refreshToken: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
interface RegistrationPayload extends LoginPayload {
|
||||
confirmationEmailSent: boolean;
|
||||
}
|
||||
|
||||
interface UpdateUsernamePayload {
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface UpdateEmailPayload {
|
||||
userAccountId: string;
|
||||
email: string;
|
||||
emailConfirmed: boolean;
|
||||
}
|
||||
|
||||
interface UpdateProfilePayload {
|
||||
userAccountId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
dateOfBirth: string;
|
||||
}
|
||||
|
||||
interface UpdatePasswordPayload {
|
||||
userAccountId: string;
|
||||
changedAt: string;
|
||||
}
|
||||
|
||||
export interface UserAccountDetails {
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
dateOfBirth: string;
|
||||
}
|
||||
|
||||
const sessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: '__session',
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 21, // 21 days (matches refresh token)
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secrets: [process.env.SESSION_SECRET || 'dev-secret-change-me'],
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
});
|
||||
|
||||
export const getSession = async (request: Request) => {
|
||||
return sessionStorage.getSession(request.headers.get('Cookie'));
|
||||
};
|
||||
|
||||
export const commitSession = async (session: Awaited<ReturnType<typeof getSession>>) => {
|
||||
return sessionStorage.commitSession(session);
|
||||
};
|
||||
|
||||
export const destroySession = async (session: Awaited<ReturnType<typeof getSession>>) => {
|
||||
return sessionStorage.destroySession(session);
|
||||
};
|
||||
|
||||
export const requireAuth = async (request: Request): Promise<AuthTokens> => {
|
||||
const session = await getSession(request);
|
||||
const accessToken = session.get('accessToken');
|
||||
const refreshToken = session.get('refreshToken');
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw redirect('/login');
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
userAccountId: session.get('userAccountId'),
|
||||
username: session.get('username'),
|
||||
};
|
||||
};
|
||||
|
||||
export const getOptionalAuth = async (request: Request): Promise<AuthTokens | null> => {
|
||||
const session = await getSession(request);
|
||||
const accessToken = session.get('accessToken');
|
||||
|
||||
if (!accessToken) return null;
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: session.get('refreshToken'),
|
||||
userAccountId: session.get('userAccountId'),
|
||||
username: session.get('username'),
|
||||
};
|
||||
};
|
||||
|
||||
export const login = async (username: string, password: string) => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Login failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<LoginPayload> = await res.json();
|
||||
return data.payload;
|
||||
};
|
||||
|
||||
export const register = async (body: {
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
dateOfBirth: string;
|
||||
password: string;
|
||||
}) => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Registration failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<RegistrationPayload> = await res.json();
|
||||
return data.payload;
|
||||
};
|
||||
|
||||
export const refreshTokens = async (refreshToken: string) => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Refresh-Token': refreshToken },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
|
||||
const data: ApiResponse<LoginPayload> = await res.json();
|
||||
return data.payload;
|
||||
};
|
||||
|
||||
export const getUserAccount = async (userAccountId: string): Promise<UserAccountDetails> => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/user/${userAccountId}`);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Failed to load account details (${res.status})`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const confirmEmail = async (token: string, accessToken: string) => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/confirm?token=${encodeURIComponent(token)}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Confirmation failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<{ userAccountId: string; confirmedDate: string }> = await res.json();
|
||||
return data.payload;
|
||||
};
|
||||
|
||||
const authorizedRequest = async <T>(
|
||||
path: string,
|
||||
accessToken: string,
|
||||
init: { method: string; body?: unknown },
|
||||
): Promise<T> => {
|
||||
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: init.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...(init.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: init.body ? JSON.stringify(init.body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<T> = await res.json();
|
||||
return data.payload;
|
||||
};
|
||||
|
||||
export const updateUsername = async (accessToken: string, newUsername: string) => {
|
||||
return authorizedRequest<UpdateUsernamePayload>('/api/auth/username', accessToken, {
|
||||
method: 'PATCH',
|
||||
body: { newUsername },
|
||||
});
|
||||
};
|
||||
|
||||
export const updateEmail = async (accessToken: string, newEmail: string) => {
|
||||
return authorizedRequest<UpdateEmailPayload>('/api/auth/email', accessToken, {
|
||||
method: 'PATCH',
|
||||
body: { newEmail },
|
||||
});
|
||||
};
|
||||
|
||||
export const updateProfile = async (
|
||||
accessToken: string,
|
||||
firstName: string,
|
||||
lastName: string,
|
||||
dateOfBirth: string,
|
||||
) => {
|
||||
return authorizedRequest<UpdateProfilePayload>('/api/auth/profile', accessToken, {
|
||||
method: 'PATCH',
|
||||
body: { firstName, lastName, dateOfBirth },
|
||||
});
|
||||
};
|
||||
|
||||
export const updatePassword = async (
|
||||
accessToken: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
) => {
|
||||
return authorizedRequest<UpdatePasswordPayload>('/api/auth/password', accessToken, {
|
||||
method: 'PATCH',
|
||||
body: { currentPassword, newPassword },
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteAccount = async (accessToken: string) => {
|
||||
await authorizedRequest<null>('/api/auth/account', accessToken, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
export const createAuthSession = async (payload: LoginPayload, redirectTo: string) => {
|
||||
const session = await sessionStorage.getSession();
|
||||
session.set('accessToken', payload.accessToken);
|
||||
session.set('refreshToken', payload.refreshToken);
|
||||
session.set('userAccountId', payload.userAccountId);
|
||||
session.set('username', payload.username);
|
||||
|
||||
return redirect(redirectTo, {
|
||||
headers: { 'Set-Cookie': await commitSession(session) },
|
||||
});
|
||||
};
|
||||
25
web/frontend/app/features/auth/components/ConfirmFailure.tsx
Normal file
25
web/frontend/app/features/auth/components/ConfirmFailure.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface ConfirmFailureProps {
|
||||
error: string;
|
||||
}
|
||||
|
||||
const ConfirmFailure = ({ error }: ConfirmFailureProps) => (
|
||||
<>
|
||||
<div className="text-error text-6xl">✕</div>
|
||||
<h1 className="card-title text-2xl">Confirmation Failed</h1>
|
||||
<div role="alert" className="alert alert-error alert-soft w-full">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
<p className="text-base-content/70 text-sm">
|
||||
The confirmation link may have expired (valid for 30 minutes) or already been used.
|
||||
</p>
|
||||
<div className="card-actions w-full pt-2 flex-col gap-2">
|
||||
<Link to="/dashboard" className="btn btn-primary w-full">
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default ConfirmFailure;
|
||||
26
web/frontend/app/features/auth/components/ConfirmSuccess.tsx
Normal file
26
web/frontend/app/features/auth/components/ConfirmSuccess.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface ConfirmSuccessProps {
|
||||
confirmedDate: string;
|
||||
}
|
||||
|
||||
const ConfirmSuccess = ({ confirmedDate }: ConfirmSuccessProps) => (
|
||||
<>
|
||||
<div className="text-success text-6xl">✓</div>
|
||||
<h1 className="card-title text-2xl">Email Confirmed!</h1>
|
||||
<p className="text-base-content/70">Your email address has been successfully verified.</p>
|
||||
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
|
||||
<span className="text-base-content/50 text-xs uppercase tracking-widest font-semibold">
|
||||
Confirmed at
|
||||
</span>
|
||||
<p className="font-mono mt-1">{new Date(confirmedDate).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="card-actions w-full pt-2">
|
||||
<Link to="/dashboard" className="btn btn-primary w-full">
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default ConfirmSuccess;
|
||||
18
web/frontend/app/features/auth/components/LoginCallout.tsx
Normal file
18
web/frontend/app/features/auth/components/LoginCallout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const LoginCallout = () => (
|
||||
<>
|
||||
<div className="divider text-xs">Already have an account?</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<Link to="/login" className="btn btn-outline btn-sm w-full">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link to="/" className="link link-hover text-sm text-base-content/60">
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default LoginCallout;
|
||||
44
web/frontend/app/features/auth/components/LoginForm.tsx
Normal file
44
web/frontend/app/features/auth/components/LoginForm.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { BaseSyntheticEvent } from 'react';
|
||||
import { FormState, UseFormRegister } from 'react-hook-form';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import type { LoginSchema } from '../schemas';
|
||||
|
||||
interface LoginFormProps {
|
||||
onSubmit: (e?: BaseSyntheticEvent) => Promise<Awaited<void> | undefined>;
|
||||
formState: FormState<LoginSchema>;
|
||||
register: UseFormRegister<{ username: string; password: string }>;
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
const LoginForm = (props: LoginFormProps) => (
|
||||
<form onSubmit={props.onSubmit} className="space-y-3">
|
||||
<FormField
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="your_username"
|
||||
label="Username"
|
||||
error={props.formState.errors.username?.message}
|
||||
{...props.register('username')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
label="Password"
|
||||
error={props.formState.errors.password?.message}
|
||||
{...props.register('password')}
|
||||
/>
|
||||
|
||||
<SubmitButton
|
||||
isSubmitting={props.submitting}
|
||||
idleText="Sign In"
|
||||
submittingText="Signing in..."
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
export default LoginForm;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { HomeSimpleDoor, UserPlus } from 'iconoir-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const RegisterCallout = () => (
|
||||
<>
|
||||
<div className="divider text-xs">New here?</div>
|
||||
<div className="text-center space-y-2">
|
||||
<Link to="/register" className="btn btn-outline btn-sm w-full gap-2">
|
||||
<UserPlus className="size-4" aria-hidden="true" />
|
||||
Create an account
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="link link-hover text-sm text-base-content/60 inline-flex items-center gap-1"
|
||||
>
|
||||
<HomeSimpleDoor className="size-4" aria-hidden="true" />
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default RegisterCallout;
|
||||
97
web/frontend/app/features/auth/components/RegisterForm.tsx
Normal file
97
web/frontend/app/features/auth/components/RegisterForm.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { BaseSyntheticEvent } from 'react';
|
||||
import { FormState, UseFormRegister } from 'react-hook-form';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import type { RegisterSchema } from '../schemas';
|
||||
|
||||
interface RegisterFormProps {
|
||||
onSubmit: (e?: BaseSyntheticEvent) => Promise<Awaited<void> | undefined>;
|
||||
formState: FormState<RegisterSchema>;
|
||||
register: UseFormRegister<RegisterSchema>;
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
const RegisterForm = (props: RegisterFormProps) => (
|
||||
<form onSubmit={props.onSubmit} className="space-y-3">
|
||||
<FormField
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="your_username"
|
||||
label="Username"
|
||||
hint="3-64 characters, alphanumeric and . _ -"
|
||||
error={props.formState.errors.username?.message}
|
||||
{...props.register('username')}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
id="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="Jane"
|
||||
label="First Name"
|
||||
error={props.formState.errors.firstName?.message}
|
||||
{...props.register('firstName')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="lastName"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Doe"
|
||||
label="Last Name"
|
||||
error={props.formState.errors.lastName?.message}
|
||||
{...props.register('lastName')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="jane@example.com"
|
||||
label="Email"
|
||||
error={props.formState.errors.email?.message}
|
||||
{...props.register('email')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="dateOfBirth"
|
||||
type="date"
|
||||
label="Date of Birth"
|
||||
hint="Must be 19 years or older"
|
||||
error={props.formState.errors.dateOfBirth?.message}
|
||||
{...props.register('dateOfBirth')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="Password"
|
||||
hint="8+ chars: uppercase, lowercase, digit, special character"
|
||||
error={props.formState.errors.password?.message}
|
||||
{...props.register('password')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="Confirm Password"
|
||||
error={props.formState.errors.confirmPassword?.message}
|
||||
{...props.register('confirmPassword')}
|
||||
/>
|
||||
|
||||
<SubmitButton
|
||||
isSubmitting={props.submitting}
|
||||
idleText="Create Account"
|
||||
submittingText="Creating account..."
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
|
||||
export default RegisterForm;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { showErrorToast } from '../../../components/ui/toast/toast';
|
||||
|
||||
export const useActionErrorToast = (error: string | undefined | null) => {
|
||||
useEffect(() => {
|
||||
if (error) showErrorToast(error);
|
||||
}, [error]);
|
||||
};
|
||||
49
web/frontend/app/features/auth/routes/confirm.tsx
Normal file
49
web/frontend/app/features/auth/routes/confirm.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { confirmEmail, requireAuth } from '../auth.server';
|
||||
import ConfirmFailure from '../components/ConfirmFailure';
|
||||
import ConfirmSuccess from '../components/ConfirmSuccess';
|
||||
import type { Route } from './+types/confirm';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [{ title: 'Confirm Email | The Biergarten App' }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await requireAuth(request);
|
||||
const url = new URL(request.url);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return { success: false as const, error: 'Missing confirmation token.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await confirmEmail(token, auth.accessToken);
|
||||
return {
|
||||
success: true as const,
|
||||
confirmedDate: payload.confirmedDate,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: err instanceof Error ? err.message : 'Confirmation failed.',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Confirm = ({ loaderData }: Route.ComponentProps) => {
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div className="card-body items-center text-center gap-4">
|
||||
{loaderData.success ? (
|
||||
<ConfirmSuccess confirmedDate={loaderData.confirmedDate} />
|
||||
) : (
|
||||
<ConfirmFailure error={loaderData.error} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Confirm;
|
||||
88
web/frontend/app/features/auth/routes/login.tsx
Normal file
88
web/frontend/app/features/auth/routes/login.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { LogIn } from 'iconoir-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { redirect, useNavigation, useSubmit } from 'react-router';
|
||||
import { createAuthSession, getOptionalAuth, login } from '../auth.server';
|
||||
import LoginForm from '../components/LoginForm';
|
||||
import RegisterCallout from '../components/RegisterCallout';
|
||||
import { useActionErrorToast } from '../hooks/useActionErrorToast';
|
||||
import { loginSchema, type LoginSchema } from '../schemas';
|
||||
import type { Route } from './+types/login';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [{ title: 'Login | The Biergarten App' }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await getOptionalAuth(request);
|
||||
if (auth) throw redirect('/dashboard');
|
||||
return null;
|
||||
};
|
||||
|
||||
export const action = async ({ request }: Route.ActionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const result = loginSchema.safeParse({
|
||||
username: formData.get('username'),
|
||||
password: formData.get('password'),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { error: result.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await login(result.data.username, result.data.password);
|
||||
return createAuthSession(payload, '/dashboard');
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : 'Login failed.' };
|
||||
}
|
||||
};
|
||||
|
||||
const Login = ({ actionData }: Route.ComponentProps) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
const { register, handleSubmit, formState } = useForm<LoginSchema>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
submit(data, { method: 'post' });
|
||||
});
|
||||
|
||||
useActionErrorToast(actionData?.error);
|
||||
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<div className="text-center">
|
||||
<h1 className="card-title text-3xl justify-center gap-2">
|
||||
<LogIn className="size-7" aria-hidden="true" />
|
||||
Login
|
||||
</h1>
|
||||
<p className="text-base-content/70">Sign in to your Biergarten account</p>
|
||||
</div>
|
||||
|
||||
{actionData?.error && (
|
||||
<div role="alert" className="alert alert-error alert-soft">
|
||||
<span>{actionData.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LoginForm
|
||||
onSubmit={onSubmit}
|
||||
formState={formState}
|
||||
register={register}
|
||||
submitting={isSubmitting}
|
||||
/>
|
||||
|
||||
<RegisterCallout />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
10
web/frontend/app/features/auth/routes/logout.tsx
Normal file
10
web/frontend/app/features/auth/routes/logout.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { redirect } from 'react-router';
|
||||
import { destroySession, getSession } from '../auth.server';
|
||||
import type { Route } from './+types/logout';
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const session = await getSession(request);
|
||||
return redirect('/', {
|
||||
headers: { 'Set-Cookie': await destroySession(session) },
|
||||
});
|
||||
};
|
||||
106
web/frontend/app/features/auth/routes/register.tsx
Normal file
106
web/frontend/app/features/auth/routes/register.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { redirect, useNavigation, useSubmit } from 'react-router';
|
||||
import { createAuthSession, getOptionalAuth, register } from '../auth.server';
|
||||
import LoginCallout from '../components/LoginCallout';
|
||||
import RegisterForm from '../components/RegisterForm';
|
||||
import { useActionErrorToast } from '../hooks/useActionErrorToast';
|
||||
import { registerSchema, type RegisterSchema } from '../schemas';
|
||||
import type { Route } from './+types/register';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [{ title: 'Register | The Biergarten App' }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await getOptionalAuth(request);
|
||||
if (auth) throw redirect('/dashboard');
|
||||
return null;
|
||||
};
|
||||
|
||||
export const action = async ({ request }: Route.ActionArgs) => {
|
||||
const formData = await request.formData();
|
||||
const result = registerSchema.safeParse({
|
||||
username: formData.get('username'),
|
||||
firstName: formData.get('firstName'),
|
||||
lastName: formData.get('lastName'),
|
||||
email: formData.get('email'),
|
||||
dateOfBirth: formData.get('dateOfBirth'),
|
||||
password: formData.get('password'),
|
||||
confirmPassword: formData.get('confirmPassword'),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const fieldErrors = result.error.flatten().fieldErrors as Record<
|
||||
keyof RegisterSchema,
|
||||
string[] | undefined
|
||||
>;
|
||||
return { error: null, fieldErrors };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = {
|
||||
username: result.data.username,
|
||||
firstName: result.data.firstName,
|
||||
lastName: result.data.lastName,
|
||||
email: result.data.email,
|
||||
dateOfBirth: result.data.dateOfBirth,
|
||||
password: result.data.password,
|
||||
};
|
||||
const payload = await register(body);
|
||||
return createAuthSession(payload, '/dashboard');
|
||||
} catch (err) {
|
||||
return {
|
||||
error: err instanceof Error ? err.message : 'Registration failed.',
|
||||
fieldErrors: null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const Register = ({ actionData }: Route.ComponentProps) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
const {
|
||||
register: field,
|
||||
handleSubmit,
|
||||
formState,
|
||||
} = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
submit(data, { method: 'post' });
|
||||
});
|
||||
|
||||
useActionErrorToast(actionData?.error);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
|
||||
<div className="card w-full max-w-lg bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<div className="text-center">
|
||||
<h1 className="card-title text-3xl justify-center">Register</h1>
|
||||
<p className="text-base-content/70">Create your Biergarten account</p>
|
||||
</div>
|
||||
|
||||
{actionData?.error && (
|
||||
<div role="alert" className="alert alert-error alert-soft">
|
||||
<span>{actionData.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RegisterForm
|
||||
onSubmit={onSubmit}
|
||||
formState={formState}
|
||||
register={field}
|
||||
submitting={isSubmitting}
|
||||
/>
|
||||
|
||||
<LoginCallout />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
33
web/frontend/app/features/auth/schemas.ts
Normal file
33
web/frontend/app/features/auth/schemas.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
export type LoginSchema = z.infer<typeof loginSchema>;
|
||||
|
||||
export const registerSchema = z
|
||||
.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(20, 'Username must be at most 20 characters'),
|
||||
firstName: z.string().min(1, 'First name is required'),
|
||||
lastName: z.string().min(1, 'Last name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain a lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain a number'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords must match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
export type RegisterSchema = z.infer<typeof registerSchema>;
|
||||
55
web/frontend/app/features/breweries/breweries.server.ts
Normal file
55
web/frontend/app/features/breweries/breweries.server.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
message: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
export interface BreweryLocation {
|
||||
breweryPostLocationId: string;
|
||||
breweryPostId: string;
|
||||
cityId: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string | null;
|
||||
postalCode: string;
|
||||
}
|
||||
|
||||
export interface Brewery {
|
||||
breweryPostId: string;
|
||||
postedById: string;
|
||||
breweryName: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string | null;
|
||||
location: BreweryLocation | null;
|
||||
}
|
||||
|
||||
export const getBreweries = async (limit?: number, offset?: number): Promise<Brewery[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (limit !== undefined) params.set('limit', String(limit));
|
||||
if (offset !== undefined) params.set('offset', String(offset));
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/brewery?${params}`);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load breweries (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<Brewery[]> = await res.json();
|
||||
return data.payload;
|
||||
};
|
||||
|
||||
export const getBreweryById = async (id: string): Promise<Brewery | null> => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/brewery/${encodeURIComponent(id)}`);
|
||||
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load brewery (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<Brewery> = await res.json();
|
||||
return data.payload;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Link } from 'react-router';
|
||||
import type { Brewery } from '../breweries.server';
|
||||
|
||||
interface BreweryCardProps {
|
||||
brewery: Brewery;
|
||||
}
|
||||
|
||||
const BreweryCard = ({ brewery }: BreweryCardProps) => (
|
||||
<Link
|
||||
to={`/breweries/${brewery.breweryPostId}`}
|
||||
className="card bg-base-100 shadow hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">{brewery.breweryName}</h2>
|
||||
<p className="text-base-content/70 line-clamp-3">{brewery.description}</p>
|
||||
{brewery.location && (
|
||||
<p className="text-sm text-base-content/50 mt-2">
|
||||
{brewery.location.addressLine1}
|
||||
{brewery.location.addressLine2 ? `, ${brewery.location.addressLine2}` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default BreweryCard;
|
||||
39
web/frontend/app/features/breweries/routes/breweries.tsx
Normal file
39
web/frontend/app/features/breweries/routes/breweries.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import BreweryCard from '../components/BreweryCard';
|
||||
import { getBreweries } from '../breweries.server';
|
||||
import type { Route } from './+types/breweries';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [{ title: 'Breweries | The Biergarten App' }];
|
||||
};
|
||||
|
||||
export const loader = async () => {
|
||||
const breweries = await getBreweries();
|
||||
return { breweries };
|
||||
};
|
||||
|
||||
const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { breweries } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
|
||||
<p className="text-base-content/70 mb-6">Discover our partner breweries.</p>
|
||||
|
||||
{breweries.length === 0 ? (
|
||||
<div className="alert alert-info alert-soft">
|
||||
<span>No breweries have been posted yet.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{breweries.map((brewery) => (
|
||||
<BreweryCard key={brewery.breweryPostId} brewery={brewery} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Breweries;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { data, Link } from 'react-router';
|
||||
import { getBreweryById } from '../breweries.server';
|
||||
import type { Route } from './+types/brewery-detail';
|
||||
|
||||
export const loader = async ({ params }: Route.LoaderArgs) => {
|
||||
const brewery = await getBreweryById(params.id);
|
||||
if (!brewery) {
|
||||
throw data('Brewery not found.', { status: 404 });
|
||||
}
|
||||
|
||||
return { brewery };
|
||||
};
|
||||
|
||||
const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { brewery } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4 max-w-2xl">
|
||||
<Link
|
||||
to="/breweries"
|
||||
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
|
||||
>
|
||||
← Back to Breweries
|
||||
</Link>
|
||||
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h1 className="card-title text-3xl">{brewery.breweryName}</h1>
|
||||
<p className="text-base-content/70 whitespace-pre-line">
|
||||
{brewery.description}
|
||||
</p>
|
||||
|
||||
{brewery.location && (
|
||||
<div className="bg-base-200 rounded-box p-4 mt-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-2">
|
||||
Location
|
||||
</p>
|
||||
<p>{brewery.location.addressLine1}</p>
|
||||
{brewery.location.addressLine2 && (
|
||||
<p>{brewery.location.addressLine2}</p>
|
||||
)}
|
||||
<p>{brewery.location.postalCode}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-base-content/40 mt-4">
|
||||
Posted {new Date(brewery.createdAt).toLocaleDateString()}
|
||||
{brewery.updatedAt &&
|
||||
` · Updated ${new Date(brewery.updatedAt).toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BreweryDetail;
|
||||
18
web/frontend/app/features/catalog/routes/beer-styles.tsx
Normal file
18
web/frontend/app/features/catalog/routes/beer-styles.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Route } from './+types/beer-styles';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [{ title: 'Beer Styles | The Biergarten App' }];
|
||||
};
|
||||
|
||||
const BeerStyles = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Beer Styles</h1>
|
||||
<p className="text-base-content/70">Learn about different beer styles.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BeerStyles;
|
||||
18
web/frontend/app/features/catalog/routes/beers.tsx
Normal file
18
web/frontend/app/features/catalog/routes/beers.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Route } from './+types/beers';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [{ title: 'Beers | The Biergarten App' }];
|
||||
};
|
||||
|
||||
const Beers = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Beers</h1>
|
||||
<p className="text-base-content/70">Explore our collection of beers.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Beers;
|
||||
56
web/frontend/app/features/home/routes/home.tsx
Normal file
56
web/frontend/app/features/home/routes/home.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Link } from 'react-router';
|
||||
import { getOptionalAuth } from '../../auth/auth.server';
|
||||
import type { Route } from './+types/home';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => [
|
||||
{ title: 'The Biergarten App' },
|
||||
{ name: 'description', content: 'Welcome to The Biergarten App' },
|
||||
];
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await getOptionalAuth(request);
|
||||
return { username: auth?.username ?? null };
|
||||
};
|
||||
|
||||
const Home = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { username } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="hero-content text-center">
|
||||
<div className="max-w-md space-y-6">
|
||||
<h1 className="text-5xl font-bold">🍺 The Biergarten App</h1>
|
||||
<p className="text-lg text-base-content/70">Authentication Demo</p>
|
||||
|
||||
{username ? (
|
||||
<>
|
||||
<p className="text-base-content/80">
|
||||
Welcome back,{' '}
|
||||
<span className="font-semibold text-primary">{username}</span>!
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link to="/dashboard" className="btn btn-primary">
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link to="/logout" className="btn btn-ghost">
|
||||
Logout
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link to="/login" className="btn btn-primary">
|
||||
Login
|
||||
</Link>
|
||||
<Link to="/register" className="btn btn-outline">
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
87
web/frontend/app/features/theme/components/ThemeSwitcher.tsx
Normal file
87
web/frontend/app/features/theme/components/ThemeSwitcher.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
biergartenThemes,
|
||||
defaultThemeName,
|
||||
isBiergartenTheme,
|
||||
type ThemeName,
|
||||
themeStorageKey,
|
||||
} from '../themes';
|
||||
|
||||
const applyTheme = (theme: ThemeName) => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(themeStorageKey, theme);
|
||||
};
|
||||
|
||||
const ThemeSwitcher = () => {
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultThemeName;
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem(themeStorageKey);
|
||||
return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
|
||||
});
|
||||
|
||||
const activeTheme =
|
||||
biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h1 className="card-title text-3xl sm:text-4xl">Theme Guide</h1>
|
||||
<p className="text-base-content/70">
|
||||
Four themes, four moods — from the sun-bleached clarity of a Weizen
|
||||
afternoon to the deep berry dark of a Cassis barrel. Every theme shares the
|
||||
same semantic token structure so components stay consistent while the
|
||||
atmosphere shifts completely.
|
||||
</p>
|
||||
<div className="alert alert-info alert-soft">
|
||||
<span>
|
||||
Active theme: <strong>{activeTheme.label}</strong> — {activeTheme.vibe}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h2 className="card-title text-2xl">Theme switcher</h2>
|
||||
<p className="text-base-content/70">Pick a theme and preview it immediately.</p>
|
||||
|
||||
<div
|
||||
className="join join-vertical sm:join-horizontal"
|
||||
role="radiogroup"
|
||||
aria-label="Theme selector"
|
||||
>
|
||||
{biergartenThemes.map((theme) => {
|
||||
const checked = selectedTheme === theme.value;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={theme.value}
|
||||
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value={theme.value}
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedTheme(theme.value);
|
||||
applyTheme(theme.value);
|
||||
}}
|
||||
/>
|
||||
{theme.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeSwitcher;
|
||||
99
web/frontend/app/features/theme/routes/theme.tsx
Normal file
99
web/frontend/app/features/theme/routes/theme.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import ThemeSwitcher from '../components/ThemeSwitcher';
|
||||
import type { Route } from './+types/theme';
|
||||
|
||||
export const meta = ({}: Route.MetaArgs) => {
|
||||
return [
|
||||
{ title: 'Theme | The Biergarten App' },
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Theme guide and switcher for The Biergarten App',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const ThemePage = () => {
|
||||
return (
|
||||
<main className="min-h-screen bg-base-200 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||||
<ThemeSwitcher />
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="card border border-base-300 bg-base-100 shadow-lg">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Brand colors</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm font-medium">
|
||||
<div className="rounded-box bg-primary p-3 text-primary-content">
|
||||
Primary
|
||||
</div>
|
||||
<div className="rounded-box bg-secondary p-3 text-secondary-content">
|
||||
Secondary
|
||||
</div>
|
||||
<div className="rounded-box bg-accent p-3 text-accent-content">
|
||||
Accent
|
||||
</div>
|
||||
<div className="rounded-box bg-neutral p-3 text-neutral-content">
|
||||
Neutral
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card border border-base-300 bg-base-100 shadow-lg">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Status colors</h3>
|
||||
<div className="space-y-2 text-sm font-medium">
|
||||
<div className="rounded-box bg-info p-3 text-info-content">
|
||||
Info
|
||||
</div>
|
||||
<div className="rounded-box bg-success p-3 text-success-content">
|
||||
Success
|
||||
</div>
|
||||
<div className="rounded-box bg-warning p-3 text-warning-content">
|
||||
Warning
|
||||
</div>
|
||||
<div className="rounded-box bg-error p-3 text-error-content">
|
||||
Error
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card border border-base-300 bg-base-100 shadow-lg md:col-span-2 xl:col-span-1">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Core style outline</h3>
|
||||
<ul className="list list-disc space-y-2 pl-5 text-base-content/80">
|
||||
<li>Warm serif headings paired with clear sans-serif body text</li>
|
||||
<li>Rounded, tactile surfaces with subtle depth and grain</li>
|
||||
<li>
|
||||
Semantic token usage to keep contrast consistent in both themes
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h2 className="card-title text-2xl">Component preview</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button className="btn btn-primary">Primary action</button>
|
||||
<button className="btn btn-secondary">Secondary action</button>
|
||||
<button className="btn btn-accent">Accent action</button>
|
||||
<button className="btn btn-ghost">Ghost action</button>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div role="alert" className="alert alert-success alert-soft">
|
||||
<span>Theme tokens are applied consistently.</span>
|
||||
</div>
|
||||
<div role="alert" className="alert alert-warning alert-soft">
|
||||
<span>Use semantic colors over hard-coded color values.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemePage;
|
||||
38
web/frontend/app/features/theme/themes.ts
Normal file
38
web/frontend/app/features/theme/themes.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type ThemeName =
|
||||
'biergarten-lager' | 'biergarten-stout' | 'biergarten-cassis' | 'biergarten-weizen';
|
||||
|
||||
export interface ThemeOption {
|
||||
value: ThemeName;
|
||||
label: string;
|
||||
vibe: string;
|
||||
}
|
||||
|
||||
export const defaultThemeName: ThemeName = 'biergarten-lager';
|
||||
export const themeStorageKey = 'biergarten-theme';
|
||||
|
||||
export const biergartenThemes: ThemeOption[] = [
|
||||
{
|
||||
value: 'biergarten-lager',
|
||||
label: 'Biergarten Lager',
|
||||
vibe: 'Muted parchment, mellow amber, daytime beer garden',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-stout',
|
||||
label: 'Biergarten Stout',
|
||||
vibe: 'Charred barrel, deep roast, cozy evening cellar',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-cassis',
|
||||
label: 'Biergarten Cassis',
|
||||
vibe: 'Blackberry barrel, sour berry dark, vivid night market',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-weizen',
|
||||
label: 'Biergarten Weizen',
|
||||
vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
|
||||
},
|
||||
];
|
||||
|
||||
export const isBiergartenTheme = (value: string | null | undefined): value is ThemeName => {
|
||||
return biergartenThemes.some((theme) => theme.value === value);
|
||||
};
|
||||
@@ -1,162 +0,0 @@
|
||||
import { createCookieSessionStorage, redirect } from 'react-router';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
message: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
interface LoginPayload {
|
||||
userAccountId: string;
|
||||
username: string;
|
||||
refreshToken: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
interface RegistrationPayload extends LoginPayload {
|
||||
confirmationEmailSent: boolean;
|
||||
}
|
||||
|
||||
const sessionStorage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: '__session',
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24 * 21, // 21 days (matches refresh token)
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secrets: [process.env.SESSION_SECRET || 'dev-secret-change-me'],
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
});
|
||||
|
||||
export async function getSession(request: Request) {
|
||||
return sessionStorage.getSession(request.headers.get('Cookie'));
|
||||
}
|
||||
|
||||
export async function commitSession(session: Awaited<ReturnType<typeof getSession>>) {
|
||||
return sessionStorage.commitSession(session);
|
||||
}
|
||||
|
||||
export async function destroySession(session: Awaited<ReturnType<typeof getSession>>) {
|
||||
return sessionStorage.destroySession(session);
|
||||
}
|
||||
|
||||
export async function requireAuth(request: Request): Promise<AuthTokens> {
|
||||
const session = await getSession(request);
|
||||
const accessToken = session.get('accessToken');
|
||||
const refreshToken = session.get('refreshToken');
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw redirect('/login');
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
userAccountId: session.get('userAccountId'),
|
||||
username: session.get('username'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOptionalAuth(request: Request): Promise<AuthTokens | null> {
|
||||
const session = await getSession(request);
|
||||
const accessToken = session.get('accessToken');
|
||||
|
||||
if (!accessToken) return null;
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: session.get('refreshToken'),
|
||||
userAccountId: session.get('userAccountId'),
|
||||
username: session.get('username'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Login failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<LoginPayload> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
export async function register(body: {
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
dateOfBirth: string;
|
||||
password: string;
|
||||
}) {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Registration failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<RegistrationPayload> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
export async function refreshTokens(refreshToken: string) {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
|
||||
const data: ApiResponse<LoginPayload> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
export async function confirmEmail(token: string, accessToken: string) {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/confirm?token=${encodeURIComponent(token)}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `Confirmation failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<{ userAccountId: string; confirmedDate: string }> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
export async function createAuthSession(payload: LoginPayload, redirectTo: string) {
|
||||
const session = await sessionStorage.getSession();
|
||||
session.set('accessToken', payload.accessToken);
|
||||
session.set('refreshToken', payload.refreshToken);
|
||||
session.set('userAccountId', payload.userAccountId);
|
||||
session.set('username', payload.username);
|
||||
|
||||
return redirect(redirectTo, {
|
||||
headers: { 'Set-Cookie': await commitSession(session) },
|
||||
});
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
message: string;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
export interface BreweryLocation {
|
||||
breweryPostLocationId: string;
|
||||
breweryPostId: string;
|
||||
cityId: string;
|
||||
addressLine1: string;
|
||||
addressLine2: string | null;
|
||||
postalCode: string;
|
||||
}
|
||||
|
||||
export interface Brewery {
|
||||
breweryPostId: string;
|
||||
postedById: string;
|
||||
breweryName: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string | null;
|
||||
location: BreweryLocation | null;
|
||||
}
|
||||
|
||||
export async function getBreweries(limit?: number, offset?: number): Promise<Brewery[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (limit !== undefined) params.set('limit', String(limit));
|
||||
if (offset !== undefined) params.set('offset', String(offset));
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/brewery?${params}`);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load breweries (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<Brewery[]> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
|
||||
export async function getBreweryById(id: string): Promise<Brewery | null> {
|
||||
const res = await fetch(`${API_BASE_URL}/api/brewery/${encodeURIComponent(id)}`);
|
||||
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load brewery (${res.status})`);
|
||||
}
|
||||
|
||||
const data: ApiResponse<Brewery> = await res.json();
|
||||
return data.payload;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
username: z.string().min(1, 'Username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
export type LoginSchema = z.infer<typeof loginSchema>;
|
||||
|
||||
export const registerSchema = z
|
||||
.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, 'Username must be at least 3 characters')
|
||||
.max(20, 'Username must be at most 20 characters'),
|
||||
firstName: z.string().min(1, 'First name is required'),
|
||||
lastName: z.string().min(1, 'Last name is required'),
|
||||
email: z.string().email('Invalid email address'),
|
||||
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain a lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain a number'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords must match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
export type RegisterSchema = z.infer<typeof registerSchema>;
|
||||
@@ -1,41 +0,0 @@
|
||||
export type ThemeName =
|
||||
| 'biergarten-lager'
|
||||
| 'biergarten-stout'
|
||||
| 'biergarten-cassis'
|
||||
| 'biergarten-weizen';
|
||||
|
||||
export interface ThemeOption {
|
||||
value: ThemeName;
|
||||
label: string;
|
||||
vibe: string;
|
||||
}
|
||||
|
||||
export const defaultThemeName: ThemeName = 'biergarten-lager';
|
||||
export const themeStorageKey = 'biergarten-theme';
|
||||
|
||||
export const biergartenThemes: ThemeOption[] = [
|
||||
{
|
||||
value: 'biergarten-lager',
|
||||
label: 'Biergarten Lager',
|
||||
vibe: 'Muted parchment, mellow amber, daytime beer garden',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-stout',
|
||||
label: 'Biergarten Stout',
|
||||
vibe: 'Charred barrel, deep roast, cozy evening cellar',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-cassis',
|
||||
label: 'Biergarten Cassis',
|
||||
vibe: 'Blackberry barrel, sour berry dark, vivid night market',
|
||||
},
|
||||
{
|
||||
value: 'biergarten-weizen',
|
||||
label: 'Biergarten Weizen',
|
||||
vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
|
||||
},
|
||||
];
|
||||
|
||||
export function isBiergartenTheme(value: string | null | undefined): value is ThemeName {
|
||||
return biergartenThemes.some((theme) => theme.value === value);
|
||||
}
|
||||
@@ -1,90 +1,103 @@
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from 'react-router';
|
||||
|
||||
import type { Route } from './+types/root';
|
||||
import './app.css';
|
||||
import Navbar from './components/Navbar';
|
||||
import ToastProvider from './components/toast/ToastProvider';
|
||||
import { getOptionalAuth } from './lib/auth.server';
|
||||
import Navbar from './components/ui/navbar/Navbar';
|
||||
import ToastProvider from './components/ui/toast/ToastProvider';
|
||||
import { getOptionalAuth } from './features/auth/auth.server';
|
||||
import { themeStorageKey } from './features/theme/themes';
|
||||
|
||||
const themeInitScript = `
|
||||
(function () {
|
||||
try {
|
||||
var theme = localStorage.getItem(${JSON.stringify(themeStorageKey)});
|
||||
if (theme) document.documentElement.setAttribute('data-theme', theme);
|
||||
} catch (e) {}
|
||||
})();
|
||||
`;
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{
|
||||
rel: 'preconnect',
|
||||
href: 'https://fonts.gstatic.com',
|
||||
crossOrigin: 'anonymous',
|
||||
},
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..900;1,9..40,100..900&family=Volkhov:ital,wght@0,400;0,700;1,400;1,700&display=swap',
|
||||
},
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{
|
||||
rel: 'preconnect',
|
||||
href: 'https://fonts.gstatic.com',
|
||||
crossOrigin: 'anonymous',
|
||||
},
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..900;1,9..40,100..900&family=Volkhov:ital,wght@0,400;0,700;1,400;1,700&display=swap',
|
||||
},
|
||||
];
|
||||
|
||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||
const auth = await getOptionalAuth(request);
|
||||
return { auth };
|
||||
const auth = await getOptionalAuth(request);
|
||||
return { auth };
|
||||
};
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
export const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export default function App({ loaderData }: Route.ComponentProps) {
|
||||
const { auth } = loaderData;
|
||||
return (
|
||||
<>
|
||||
<Navbar auth={auth} />
|
||||
<ToastProvider />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
const App = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { auth } = loaderData;
|
||||
return (
|
||||
<>
|
||||
<Navbar auth={auth} />
|
||||
<ToastProvider />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
let message = 'Oops!';
|
||||
let details = 'An unexpected error occurred.';
|
||||
let stack: string | undefined;
|
||||
export default App;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? '404' : 'Error';
|
||||
details =
|
||||
error.status === 404
|
||||
? 'The requested page could not be found.'
|
||||
: error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => {
|
||||
let message = 'Oops!';
|
||||
let details = 'An unexpected error occurred.';
|
||||
let stack: string | undefined;
|
||||
|
||||
return (
|
||||
<main className="pt-16 p-4 container mx-auto">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? '404' : 'Error';
|
||||
details =
|
||||
error.status === 404
|
||||
? 'The requested page could not be found.'
|
||||
: error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pt-16 p-4 container mx-auto">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { type RouteConfig, index, route } from '@react-router/dev/routes';
|
||||
|
||||
export default [
|
||||
index('routes/home.tsx'),
|
||||
route('theme', 'routes/theme.tsx'),
|
||||
route('login', 'routes/login.tsx'),
|
||||
route('register', 'routes/register.tsx'),
|
||||
route('logout', 'routes/logout.tsx'),
|
||||
route('dashboard', 'routes/dashboard.tsx'),
|
||||
route('confirm', 'routes/confirm.tsx'),
|
||||
route('beers', 'routes/beers.tsx'),
|
||||
route('breweries', 'routes/breweries.tsx'),
|
||||
route('beer-styles', 'routes/beer-styles.tsx'),
|
||||
index('features/home/routes/home.tsx'),
|
||||
route('theme', 'features/theme/routes/theme.tsx'),
|
||||
route('login', 'features/auth/routes/login.tsx'),
|
||||
route('register', 'features/auth/routes/register.tsx'),
|
||||
route('logout', 'features/auth/routes/logout.tsx'),
|
||||
route('dashboard', 'features/account/routes/dashboard.tsx'),
|
||||
route('account', 'features/account/routes/account.tsx'),
|
||||
route('confirm', 'features/auth/routes/confirm.tsx'),
|
||||
route('beers', 'features/catalog/routes/beers.tsx'),
|
||||
route('breweries', 'features/breweries/routes/breweries.tsx'),
|
||||
route('breweries/:id', 'features/breweries/routes/brewery-detail.tsx'),
|
||||
route('beer-styles', 'features/catalog/routes/beer-styles.tsx'),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Route } from './+types/beer-styles';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Beer Styles | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export default function BeerStyles() {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Beer Styles</h1>
|
||||
<p className="text-base-content/70">Learn about different beer styles.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { Route } from './+types/beers';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Beers | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export default function Beers() {
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Beers</h1>
|
||||
<p className="text-base-content/70">Explore our collection of beers.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Link } from 'react-router';
|
||||
import { getBreweries } from '../lib/breweries.server';
|
||||
import type { Route } from './+types/breweries';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Breweries | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export async function loader() {
|
||||
const breweries = await getBreweries();
|
||||
return { breweries };
|
||||
}
|
||||
|
||||
export default function Breweries({ loaderData }: Route.ComponentProps) {
|
||||
const { breweries } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
|
||||
<p className="text-base-content/70 mb-6">Discover our partner breweries.</p>
|
||||
|
||||
{breweries.length === 0 ? (
|
||||
<div className="alert alert-info alert-soft">
|
||||
<span>No breweries have been posted yet.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{breweries.map((brewery) => (
|
||||
<Link
|
||||
key={brewery.breweryPostId}
|
||||
to={`/breweries/${brewery.breweryPostId}`}
|
||||
className="card bg-base-100 shadow hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">{brewery.breweryName}</h2>
|
||||
<p className="text-base-content/70 line-clamp-3">
|
||||
{brewery.description}
|
||||
</p>
|
||||
{brewery.location && (
|
||||
<p className="text-sm text-base-content/50 mt-2">
|
||||
{brewery.location.addressLine1}
|
||||
{brewery.location.addressLine2
|
||||
? `, ${brewery.location.addressLine2}`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Link, data } from 'react-router';
|
||||
import { getBreweryById } from '../lib/breweries.server';
|
||||
import type { Route } from './+types/brewery-detail';
|
||||
|
||||
export function meta({ data }: Route.MetaArgs) {
|
||||
return [{ title: `${data?.brewery.breweryName ?? 'Brewery'} | The Biergarten App` }];
|
||||
}
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const brewery = await getBreweryById(params.id);
|
||||
if (!brewery) {
|
||||
throw data('Brewery not found.', { status: 404 });
|
||||
}
|
||||
|
||||
return { brewery };
|
||||
}
|
||||
|
||||
export default function BreweryDetail({ loaderData }: Route.ComponentProps) {
|
||||
const { brewery } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="container mx-auto p-4 max-w-2xl">
|
||||
<Link
|
||||
to="/breweries"
|
||||
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
|
||||
>
|
||||
← Back to Breweries
|
||||
</Link>
|
||||
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h1 className="card-title text-3xl">{brewery.breweryName}</h1>
|
||||
<p className="text-base-content/70 whitespace-pre-line">{brewery.description}</p>
|
||||
|
||||
{brewery.location && (
|
||||
<div className="bg-base-200 rounded-box p-4 mt-4">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-2">
|
||||
Location
|
||||
</p>
|
||||
<p>{brewery.location.addressLine1}</p>
|
||||
{brewery.location.addressLine2 && <p>{brewery.location.addressLine2}</p>}
|
||||
<p>{brewery.location.postalCode}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-base-content/40 mt-4">
|
||||
Posted {new Date(brewery.createdAt).toLocaleDateString()}
|
||||
{brewery.updatedAt &&
|
||||
` · Updated ${new Date(brewery.updatedAt).toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { showErrorToast, showSuccessToast } from '../components/toast/toast';
|
||||
import { confirmEmail, requireAuth } from '../lib/auth.server';
|
||||
import type { Route } from './+types/confirm';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Confirm Email | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const auth = await requireAuth(request);
|
||||
const url = new URL(request.url);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return { success: false as const, error: 'Missing confirmation token.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await confirmEmail(token, auth.accessToken);
|
||||
return {
|
||||
success: true as const,
|
||||
confirmedDate: payload.confirmedDate,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false as const,
|
||||
error: err instanceof Error ? err.message : 'Confirmation failed.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Confirm({ loaderData }: Route.ComponentProps) {
|
||||
useEffect(() => {
|
||||
if (loaderData.success) {
|
||||
showSuccessToast('Email confirmed successfully.');
|
||||
return;
|
||||
}
|
||||
|
||||
showErrorToast(loaderData.error);
|
||||
}, [loaderData]);
|
||||
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div className="card-body items-center text-center gap-4">
|
||||
{loaderData.success ? (
|
||||
<>
|
||||
<div className="text-success text-6xl">✓</div>
|
||||
<h1 className="card-title text-2xl">Email Confirmed!</h1>
|
||||
<p className="text-base-content/70">
|
||||
Your email address has been successfully verified.
|
||||
</p>
|
||||
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
|
||||
<span className="text-base-content/50 text-xs uppercase tracking-widest font-semibold">
|
||||
Confirmed at
|
||||
</span>
|
||||
<p className="font-mono mt-1">
|
||||
{new Date(loaderData.confirmedDate).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card-actions w-full pt-2">
|
||||
<Link to="/dashboard" className="btn btn-primary w-full">
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-error text-6xl">✕</div>
|
||||
<h1 className="card-title text-2xl">Confirmation Failed</h1>
|
||||
<div role="alert" className="alert alert-error alert-soft w-full">
|
||||
<span>{loaderData.error}</span>
|
||||
</div>
|
||||
<p className="text-base-content/70 text-sm">
|
||||
The confirmation link may have expired (valid for 30 minutes) or already
|
||||
been used.
|
||||
</p>
|
||||
<div className="card-actions w-full pt-2 flex-col gap-2">
|
||||
<Link to="/dashboard" className="btn btn-primary w-full">
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { requireAuth } from '../lib/auth.server';
|
||||
import type { Route } from './+types/dashboard';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Dashboard | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const auth = await requireAuth(request);
|
||||
return {
|
||||
username: auth.username,
|
||||
userAccountId: auth.userAccountId,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Dashboard({ loaderData }: Route.ComponentProps) {
|
||||
const { username, userAccountId } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200">
|
||||
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-2xl">Welcome, {username}!</h2>
|
||||
<p className="text-base-content/70">
|
||||
You are successfully authenticated. This is a protected page that requires a
|
||||
valid session.
|
||||
</p>
|
||||
|
||||
<div className="bg-base-200 rounded-box p-4 mt-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
|
||||
Session Info
|
||||
</p>
|
||||
<div className="stats stats-vertical w-full">
|
||||
<div className="stat py-2">
|
||||
<div className="stat-title">Username</div>
|
||||
<div className="stat-value text-lg font-mono">{username}</div>
|
||||
</div>
|
||||
<div className="stat py-2">
|
||||
<div className="stat-title">User ID</div>
|
||||
<div className="stat-desc font-mono text-xs mt-1">{userAccountId}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">Auth Flow Demo</h2>
|
||||
<p className="text-sm text-base-content/70">
|
||||
This demo showcases the following authentication features:
|
||||
</p>
|
||||
<ul className="list">
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Login</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
|
||||
username & password
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Register</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/register</code> with
|
||||
full user details
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Session</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
JWT access & refresh tokens stored in an HTTP-only cookie
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Protected Routes</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
This dashboard requires authentication via{' '}
|
||||
<code className="kbd kbd-sm">requireAuth()</code>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Token Refresh</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with
|
||||
refresh token
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Link } from 'react-router';
|
||||
import { getOptionalAuth } from '../lib/auth.server';
|
||||
import type { Route } from './+types/home';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: 'The Biergarten App' },
|
||||
{ name: 'description', content: 'Welcome to The Biergarten App' },
|
||||
];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const auth = await getOptionalAuth(request);
|
||||
return { username: auth?.username ?? null };
|
||||
}
|
||||
|
||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||
const { username } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="hero-content text-center">
|
||||
<div className="max-w-md space-y-6">
|
||||
<h1 className="text-5xl font-bold">🍺 The Biergarten App</h1>
|
||||
<p className="text-lg text-base-content/70">Authentication Demo</p>
|
||||
|
||||
{username ? (
|
||||
<>
|
||||
<p className="text-base-content/80">
|
||||
Welcome back, <span className="font-semibold text-primary">{username}</span>
|
||||
!
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link to="/dashboard" className="btn btn-primary">
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link to="/logout" className="btn btn-ghost">
|
||||
Logout
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link to="/login" className="btn btn-primary">
|
||||
Login
|
||||
</Link>
|
||||
<Link to="/register" className="btn btn-outline">
|
||||
Register
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { HomeSimpleDoor, LogIn, UserPlus } from 'iconoir-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link, redirect, useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../components/forms/FormField';
|
||||
import SubmitButton from '../components/forms/SubmitButton';
|
||||
import { showErrorToast } from '../components/toast/toast';
|
||||
import { createAuthSession, getOptionalAuth, login } from '../lib/auth.server';
|
||||
import { loginSchema, type LoginSchema } from '../lib/schemas';
|
||||
import type { Route } from './+types/login';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Login | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const auth = await getOptionalAuth(request);
|
||||
if (auth) throw redirect('/dashboard');
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
const result = loginSchema.safeParse({
|
||||
username: formData.get('username'),
|
||||
password: formData.get('password'),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { error: result.error.issues[0].message };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await login(result.data.username, result.data.password);
|
||||
return createAuthSession(payload, '/dashboard');
|
||||
} catch (err) {
|
||||
return { error: err instanceof Error ? err.message : 'Login failed.' };
|
||||
}
|
||||
}
|
||||
|
||||
export default function Login({ actionData }: Route.ComponentProps) {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginSchema>({ resolver: zodResolver(loginSchema) });
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
submit(data, { method: 'post' });
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.error) {
|
||||
showErrorToast(actionData.error);
|
||||
}
|
||||
}, [actionData?.error]);
|
||||
|
||||
return (
|
||||
<div className="hero min-h-screen bg-base-200">
|
||||
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<div className="text-center">
|
||||
<h1 className="card-title text-3xl justify-center gap-2">
|
||||
<LogIn className="size-7" aria-hidden="true" />
|
||||
Login
|
||||
</h1>
|
||||
<p className="text-base-content/70">Sign in to your Biergarten account</p>
|
||||
</div>
|
||||
|
||||
{actionData?.error && (
|
||||
<div role="alert" className="alert alert-error alert-soft">
|
||||
<span>{actionData.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<FormField
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="your_username"
|
||||
label="Username"
|
||||
error={errors.username?.message}
|
||||
{...register('username')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
label="Password"
|
||||
error={errors.password?.message}
|
||||
{...register('password')}
|
||||
/>
|
||||
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Sign In"
|
||||
submittingText="Signing in..."
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="divider text-xs">New here?</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<Link to="/register" className="btn btn-outline btn-sm w-full gap-2">
|
||||
<UserPlus className="size-4" aria-hidden="true" />
|
||||
Create an account
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
className="link link-hover text-sm text-base-content/60 inline-flex items-center gap-1"
|
||||
>
|
||||
<HomeSimpleDoor className="size-4" aria-hidden="true" />
|
||||
Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { redirect } from 'react-router';
|
||||
import { destroySession, getSession } from '../lib/auth.server';
|
||||
import type { Route } from './+types/logout';
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const session = await getSession(request);
|
||||
return redirect('/', {
|
||||
headers: { 'Set-Cookie': await destroySession(session) },
|
||||
});
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link, redirect, useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../components/forms/FormField';
|
||||
import SubmitButton from '../components/forms/SubmitButton';
|
||||
import { showErrorToast } from '../components/toast/toast';
|
||||
import { createAuthSession, getOptionalAuth, register } from '../lib/auth.server';
|
||||
import { registerSchema, type RegisterSchema } from '../lib/schemas';
|
||||
import type { Route } from './+types/register';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [{ title: 'Register | The Biergarten App' }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const auth = await getOptionalAuth(request);
|
||||
if (auth) throw redirect('/dashboard');
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
const result = registerSchema.safeParse({
|
||||
username: formData.get('username'),
|
||||
firstName: formData.get('firstName'),
|
||||
lastName: formData.get('lastName'),
|
||||
email: formData.get('email'),
|
||||
dateOfBirth: formData.get('dateOfBirth'),
|
||||
password: formData.get('password'),
|
||||
confirmPassword: formData.get('confirmPassword'),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const fieldErrors = result.error.flatten().fieldErrors;
|
||||
return { error: null, fieldErrors };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = {
|
||||
username: result.data.username,
|
||||
firstName: result.data.firstName,
|
||||
lastName: result.data.lastName,
|
||||
email: result.data.email,
|
||||
dateOfBirth: result.data.dateOfBirth,
|
||||
password: result.data.password,
|
||||
};
|
||||
const payload = await register(body);
|
||||
return createAuthSession(payload, '/dashboard');
|
||||
} catch (err) {
|
||||
return {
|
||||
error: err instanceof Error ? err.message : 'Registration failed.',
|
||||
fieldErrors: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Register({ actionData }: Route.ComponentProps) {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
|
||||
const {
|
||||
register: field,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
|
||||
|
||||
const onSubmit = handleSubmit((data) => {
|
||||
submit(data, { method: 'post' });
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.error) {
|
||||
showErrorToast(actionData.error);
|
||||
}
|
||||
}, [actionData?.error]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
|
||||
<div className="card w-full max-w-lg bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<div className="text-center">
|
||||
<h1 className="card-title text-3xl justify-center">Register</h1>
|
||||
<p className="text-base-content/70">Create your Biergarten account</p>
|
||||
</div>
|
||||
|
||||
{actionData?.error && (
|
||||
<div role="alert" className="alert alert-error alert-soft">
|
||||
<span>{actionData.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-3">
|
||||
<FormField
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder="your_username"
|
||||
label="Username"
|
||||
hint="3-64 characters, alphanumeric and . _ -"
|
||||
error={errors.username?.message}
|
||||
{...field('username')}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
id="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="Jane"
|
||||
label="First Name"
|
||||
error={errors.firstName?.message}
|
||||
{...field('firstName')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="lastName"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Doe"
|
||||
label="Last Name"
|
||||
error={errors.lastName?.message}
|
||||
{...field('lastName')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="jane@example.com"
|
||||
label="Email"
|
||||
error={errors.email?.message}
|
||||
{...field('email')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="dateOfBirth"
|
||||
type="date"
|
||||
label="Date of Birth"
|
||||
hint="Must be 19 years or older"
|
||||
error={errors.dateOfBirth?.message}
|
||||
{...field('dateOfBirth')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="Password"
|
||||
hint="8+ chars: uppercase, lowercase, digit, special character"
|
||||
error={errors.password?.message}
|
||||
{...field('password')}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="Confirm Password"
|
||||
error={errors.confirmPassword?.message}
|
||||
{...field('confirmPassword')}
|
||||
/>
|
||||
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Create Account"
|
||||
submittingText="Creating account..."
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="divider text-xs">Already have an account?</div>
|
||||
|
||||
<div className="text-center space-y-2">
|
||||
<Link to="/login" className="btn btn-outline btn-sm w-full">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link to="/" className="link link-hover text-sm text-base-content/60">
|
||||
← Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
biergartenThemes,
|
||||
defaultThemeName,
|
||||
isBiergartenTheme,
|
||||
themeStorageKey,
|
||||
type ThemeName,
|
||||
} from '../lib/themes';
|
||||
import type { Route } from './+types/theme';
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: 'Theme | The Biergarten App' },
|
||||
{
|
||||
name: 'description',
|
||||
content: 'Theme guide and switcher for The Biergarten App',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeName) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(themeStorageKey, theme);
|
||||
}
|
||||
|
||||
export default function ThemePage() {
|
||||
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return defaultThemeName;
|
||||
}
|
||||
|
||||
const savedTheme = localStorage.getItem(themeStorageKey);
|
||||
return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(selectedTheme);
|
||||
}, [selectedTheme]);
|
||||
|
||||
const activeTheme =
|
||||
biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-base-200 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||||
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h1 className="card-title text-3xl sm:text-4xl">Theme Guide</h1>
|
||||
<p className="text-base-content/70">
|
||||
Four themes, four moods — from the sun-bleached clarity of a Weizen afternoon
|
||||
to the deep berry dark of a Cassis barrel. Every theme shares the same semantic
|
||||
token structure so components stay consistent while the atmosphere shifts
|
||||
completely.
|
||||
</p>
|
||||
<div className="alert alert-info alert-soft">
|
||||
<span>
|
||||
Active theme: <strong>{activeTheme.label}</strong> — {activeTheme.vibe}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h2 className="card-title text-2xl">Theme switcher</h2>
|
||||
<p className="text-base-content/70">Pick a theme and preview it immediately.</p>
|
||||
|
||||
<div
|
||||
className="join join-vertical sm:join-horizontal"
|
||||
role="radiogroup"
|
||||
aria-label="Theme selector"
|
||||
>
|
||||
{biergartenThemes.map((theme) => {
|
||||
const checked = selectedTheme === theme.value;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={theme.value}
|
||||
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
value={theme.value}
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
setSelectedTheme(theme.value);
|
||||
applyTheme(theme.value);
|
||||
}}
|
||||
/>
|
||||
{theme.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="card border border-base-300 bg-base-100 shadow-lg">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Brand colors</h3>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm font-medium">
|
||||
<div className="rounded-box bg-primary p-3 text-primary-content">
|
||||
Primary
|
||||
</div>
|
||||
<div className="rounded-box bg-secondary p-3 text-secondary-content">
|
||||
Secondary
|
||||
</div>
|
||||
<div className="rounded-box bg-accent p-3 text-accent-content">Accent</div>
|
||||
<div className="rounded-box bg-neutral p-3 text-neutral-content">
|
||||
Neutral
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card border border-base-300 bg-base-100 shadow-lg">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Status colors</h3>
|
||||
<div className="space-y-2 text-sm font-medium">
|
||||
<div className="rounded-box bg-info p-3 text-info-content">Info</div>
|
||||
<div className="rounded-box bg-success p-3 text-success-content">
|
||||
Success
|
||||
</div>
|
||||
<div className="rounded-box bg-warning p-3 text-warning-content">
|
||||
Warning
|
||||
</div>
|
||||
<div className="rounded-box bg-error p-3 text-error-content">Error</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card border border-base-300 bg-base-100 shadow-lg md:col-span-2 xl:col-span-1">
|
||||
<div className="card-body">
|
||||
<h3 className="card-title">Core style outline</h3>
|
||||
<ul className="list list-disc space-y-2 pl-5 text-base-content/80">
|
||||
<li>Warm serif headings paired with clear sans-serif body text</li>
|
||||
<li>Rounded, tactile surfaces with subtle depth and grain</li>
|
||||
<li>Semantic token usage to keep contrast consistent in both themes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||
<div className="card-body gap-4">
|
||||
<h2 className="card-title text-2xl">Component preview</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button className="btn btn-primary">Primary action</button>
|
||||
<button className="btn btn-secondary">Secondary action</button>
|
||||
<button className="btn btn-accent">Accent action</button>
|
||||
<button className="btn btn-ghost">Ghost action</button>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div role="alert" className="alert alert-success alert-soft">
|
||||
<span>Theme tokens are applied consistently.</span>
|
||||
</div>
|
||||
<div role="alert" className="alert alert-warning alert-soft">
|
||||
<span>Use semantic colors over hard-coded color values.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user