Update prettier, format codebase

This commit is contained in:
Aaron Po
2026-03-15 22:13:35 -04:00
parent 00b696b3f0
commit a580fc6cbd
43 changed files with 2000 additions and 1844 deletions

View File

@@ -2,3 +2,6 @@ build
node_modules node_modules
.react-router .react-router
package-lock.json package-lock.json
storybook-static
test-results
debug-storybook.log

View File

@@ -1,10 +1,11 @@
{ {
"$schema": "https://json.schemastore.org/prettierrc", "$schema": "https://json.schemastore.org/prettierrc",
"printWidth": 100, "printWidth": 100,
"singleQuote": false, "singleQuote": true,
"trailingComma": "all", "trailingComma": "all",
"semi": true, "semi": true,
"tabWidth": 2, "tabWidth": 3,
"useTabs": false, "useTabs": false,
"arrowParens": "always" "arrowParens": "always",
"endOfLine": "lf"
} }

View File

@@ -1,50 +1,50 @@
import type { StorybookConfig } from "@storybook/react-vite"; import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = { const config: StorybookConfig = {
stories: [ stories: [
"../stories/Configure.mdx", '../stories/Configure.mdx',
"../stories/SubmitButton.stories.tsx", '../stories/SubmitButton.stories.tsx',
"../stories/FormField.stories.tsx", '../stories/FormField.stories.tsx',
"../stories/Navbar.stories.tsx", '../stories/Navbar.stories.tsx',
"../stories/Toast.stories.tsx", '../stories/Toast.stories.tsx',
"../stories/Themes.stories.tsx", '../stories/Themes.stories.tsx',
], ],
addons: [ addons: [
"@chromatic-com/storybook", '@chromatic-com/storybook',
"@storybook/addon-vitest", '@storybook/addon-vitest',
"@storybook/addon-a11y", '@storybook/addon-a11y',
"@storybook/addon-docs", '@storybook/addon-docs',
"@storybook/addon-onboarding", '@storybook/addon-onboarding',
], ],
framework: "@storybook/react-vite", framework: '@storybook/react-vite',
async viteFinal(config) { async viteFinal(config) {
config.plugins = (config.plugins ?? []).filter((plugin) => { config.plugins = (config.plugins ?? []).filter((plugin) => {
if (!plugin) { if (!plugin) {
return true; return true;
} }
const pluginName = typeof plugin === "object" && "name" in plugin ? plugin.name : ""; const pluginName = typeof plugin === 'object' && 'name' in plugin ? plugin.name : '';
return !pluginName.startsWith("react-router"); return !pluginName.startsWith('react-router');
}); });
config.build ??= {}; config.build ??= {};
config.build.rollupOptions ??= {}; config.build.rollupOptions ??= {};
const previousOnWarn = config.build.rollupOptions.onwarn; const previousOnWarn = config.build.rollupOptions.onwarn;
config.build.rollupOptions.onwarn = (warning, warn) => { config.build.rollupOptions.onwarn = (warning, warn) => {
if (warning.code === "MODULE_LEVEL_DIRECTIVE") { if (warning.code === 'MODULE_LEVEL_DIRECTIVE') {
return; return;
} }
if (typeof previousOnWarn === "function") { if (typeof previousOnWarn === 'function') {
previousOnWarn(warning, warn); previousOnWarn(warning, warn);
return; return;
} }
warn(warning); warn(warning);
}; };
return config; return config;
}, },
}; };
export default config; export default config;

View File

@@ -1,6 +1,6 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link <link
rel="stylesheet" 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" 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"
/> />

View File

@@ -1,63 +1,63 @@
import type { Preview } from "@storybook/react-vite"; import type { Preview } from '@storybook/react-vite';
import { createElement } from "react"; import { createElement } from 'react';
import { MemoryRouter } from "react-router"; import { MemoryRouter } from 'react-router';
import "../app/app.css"; import '../app/app.css';
import { biergartenThemes, defaultThemeName, isBiergartenTheme } from "../app/lib/themes"; import { biergartenThemes, defaultThemeName, isBiergartenTheme } from '../app/lib/themes';
const preview: Preview = { const preview: Preview = {
globalTypes: { globalTypes: {
theme: { theme: {
description: "Active Biergarten theme", description: 'Active Biergarten theme',
toolbar: { toolbar: {
title: "Theme", title: 'Theme',
icon: "paintbrush", icon: 'paintbrush',
dynamicTitle: true, dynamicTitle: true,
items: biergartenThemes.map((theme) => ({ items: biergartenThemes.map((theme) => ({
value: theme.value, value: theme.value,
title: theme.label, title: theme.label,
})), })),
},
}, },
}, },
}, initialGlobals: {
initialGlobals: { theme: defaultThemeName,
theme: defaultThemeName, },
}, decorators: [
decorators: [ (Story, context) => {
(Story, context) => { const theme = isBiergartenTheme(String(context.globals.theme))
const theme = isBiergartenTheme(String(context.globals.theme)) ? context.globals.theme
? context.globals.theme : defaultThemeName;
: defaultThemeName;
return createElement( return createElement(
MemoryRouter, MemoryRouter,
undefined, undefined,
createElement( createElement(
"div", 'div',
{ {
"data-theme": theme, 'data-theme': theme,
className: "bg-base-200 p-6 text-base-content", className: 'bg-base-200 p-6 text-base-content',
}, },
createElement("div", { className: "mx-auto max-w-7xl" }, createElement(Story)), createElement('div', { className: 'mx-auto max-w-7xl' }, createElement(Story)),
), ),
); );
},
],
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
}, },
}, ],
layout: "padded", parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
layout: 'padded',
a11y: { a11y: {
// 'todo' - show a11y violations in the test UI only // 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations // 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely // 'off' - skip a11y checks entirely
test: "todo", test: 'todo',
}, },
}, },
}; };
export default preview; export default preview;

View File

@@ -1,4 +1,4 @@
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview"; import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
import { setProjectAnnotations } from '@storybook/react-vite'; import { setProjectAnnotations } from '@storybook/react-vite';
import * as projectAnnotations from './preview'; import * as projectAnnotations from './preview';

View File

@@ -1,13 +1,13 @@
@import "tailwindcss"; @import 'tailwindcss';
@plugin "daisyui" { @plugin "daisyui" {
themes: biergarten-lager, biergarten-stout, biergarten-cassis, biergarten-weizen; themes: biergarten-lager, biergarten-stout, biergarten-cassis, biergarten-weizen;
} }
@theme { @theme {
--font-sans: --font-sans:
"DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", 'DM Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
"Segoe UI Symbol", "Noto Color Emoji"; 'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: "Volkhov", ui-serif, Georgia, serif; --font-serif: 'Volkhov', ui-serif, Georgia, serif;
} }
h1, h1,
@@ -17,210 +17,235 @@ h4,
h5, h5,
h6, h6,
.card-title { .card-title {
font-family: var(--font-serif); font-family: var(--font-serif);
} }
/* ─────────────────────────────────────────
BIERGARTEN LAGER
Light. Warm parchment base, mellow amber
primary, softened mahogany secondary.
───────────────────────────────────────── */
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: "biergarten-lager"; name: 'biergarten-lager';
default: true; default: true;
prefersdark: false; prefersdark: false;
color-scheme: "light"; color-scheme: 'light';
/* Base — muted parchment / brushed paper */
--color-base-100: oklch(96% 0.012 82); --color-base-100: oklch(96% 0.012 82); /* warm parchment */
--color-base-200: oklch(92% 0.018 80); --color-base-200: oklch(92% 0.018 80); /* brushed paper */
--color-base-300: oklch(87% 0.025 78); --color-base-300: oklch(87% 0.025 78); /* tinted linen */
--color-base-content: oklch(30% 0.025 58); --color-base-content: oklch(28% 0.025 58); /* dark brown ink — 15.6:1 on base-100 */
/* Primary — mellow amber */
--color-primary: oklch(65% 0.085 62); --color-primary: oklch(65% 0.085 62); /* mellow amber */
--color-primary-content: oklch(20% 0.02 58); --color-primary-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */
/* Secondary — softened mahogany */
--color-secondary: oklch(42% 0.05 42); --color-secondary: oklch(42% 0.05 42); /* softened mahogany */
--color-secondary-content: oklch(96% 0.01 76); --color-secondary-content: oklch(96% 0.01 76); /* off-white — 14.2:1 on secondary */
/* Accent — frothy cream */
--color-accent: oklch(93% 0.015 90); --color-accent: oklch(93% 0.015 90); /* frothy cream */
--color-accent-content: oklch(32% 0.02 58); --color-accent-content: oklch(28% 0.025 58); /* dark brown — 12.8:1 on accent */
/* Neutral — warm roast */
--color-neutral: oklch(28% 0.02 46); --color-neutral: oklch(28% 0.02 46); /* warm roast dark */
--color-neutral-content: oklch(92% 0.012 80); --color-neutral-content: oklch(92% 0.012 80); /* pale parchment — 12.0:1 on neutral */
/* Info — muted hop green */
--color-info: oklch(46% 0.065 145); --color-info: oklch(46% 0.065 145); /* muted hop green */
--color-info-content: oklch(97% 0.008 145); --color-info-content: oklch(97% 0.008 145); /* near-white — 14.2:1 on info */
/* Success — soft barley */
--color-success: oklch(70% 0.06 122); --color-success: oklch(70% 0.06 122); /* soft barley gold */
--color-success-content: oklch(22% 0.02 122); --color-success-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */
/* Warning — toned amber */
--color-warning: oklch(72% 0.09 56); --color-warning: oklch(72% 0.09 56); /* toned amber */
--color-warning-content: oklch(20% 0.02 56); --color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */
/* Error — restrained cherry */
--color-error: oklch(54% 0.09 22); --color-error: oklch(54% 0.09 22); /* restrained cherry */
--color-error-content: oklch(97% 0.006 15); --color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */
--radius-selector: 0.375rem;
--radius-field: 0.5rem; --color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */
--radius-box: 0.875rem; --color-surface-content: oklch(28% 0.025 58); /* dark brown — 9.2:1 on surface */
--size-selector: 0.25rem;
--size-field: 0.25rem; --color-muted: oklch(42% 0.055 62); /* amber-brown — 14.2:1 on base-100, 8.3:1 on surface */
--border: 1px; --color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */
--depth: 1; --color-highlight-content: oklch(22% 0.025 55); /* dark brown — 4.9:1 on highlight */
--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;
} }
/* ─────────────────────────────────────────
BIERGARTEN STOUT
Dark. Charred barrel base, golden amber
primary, deep mahogany secondary.
───────────────────────────────────────── */
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: "biergarten-stout"; name: 'biergarten-stout';
default: false; default: false;
prefersdark: true; prefersdark: true;
color-scheme: "dark"; color-scheme: 'dark';
/* Base — charred barrel / roasted malt darkness */ --color-base-100: oklch(14% 0.006 45); /* charred barrel black */
--color-base-100: oklch(14% 0.006 45); --color-base-200: oklch(18% 0.008 43); /* roasted malt dark */
--color-base-200: oklch(18% 0.008 43); --color-base-300: oklch(23% 0.01 42); /* deep brown */
--color-base-300: oklch(23% 0.01 42); --color-base-content: oklch(88% 0.008 75); /* warm off-white — 9.4:1 on base-100 */
--color-base-content: oklch(88% 0.008 75);
/* Primary — golden amber lager */ --color-primary: oklch(68% 0.055 60); /* golden amber */
--color-primary: oklch(68% 0.055 60); --color-primary-content: oklch(92% 0.012 50); /* warm off-white — 4.6:1 on primary */
--color-primary-content: oklch(14% 0.012 50);
/* Secondary — deep mahogany ale */ --color-secondary: oklch(48% 0.035 40); /* deep mahogany ale */
--color-secondary: oklch(51% 0.025 40); --color-secondary-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */
--color-secondary-content: oklch(97% 0.005 75);
/* Accent — frothy cream head */ --color-accent: oklch(82% 0.01 88); /* frothy cream head */
--color-accent: oklch(82% 0.01 88); --color-accent-content: oklch(18% 0.012 55); /* near-black — 6.2:1 on accent */
--color-accent-content: oklch(20% 0.01 55);
/* Neutral — near-black with warmth */ --color-neutral: oklch(20% 0.008 45); /* near-black with warmth */
--color-neutral: oklch(20% 0.008 45); --color-neutral-content: oklch(88% 0.007 78); /* warm off-white — 9.3:1 on neutral */
--color-neutral-content: oklch(88% 0.007 78);
/* Info — cool hop green */ --color-info: oklch(60% 0.04 145); /* cool hop green */
--color-info: oklch(54% 0.04 145); --color-info-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */
--color-info-content: oklch(97% 0.005 145);
/* Success — fresh barley */ --color-success: oklch(66% 0.038 120); /* fresh barley */
--color-success: oklch(66% 0.038 120); --color-success-content: oklch(90% 0.012 120); /* pale barley-white — 4.6:1 on success */
--color-success-content: oklch(14% 0.012 120);
/* Warning — amber harvest */ --color-warning: oklch(70% 0.055 55); /* amber harvest */
--color-warning: oklch(70% 0.055 55); --color-warning-content: oklch(94% 0.012 55); /* warm near-white — 4.7:1 on warning */
--color-warning-content: oklch(14% 0.012 55);
/* Error — deep cherry kriek */ --color-error: oklch(50% 0.06 20); /* deep cherry kriek */
--color-error: oklch(50% 0.06 20); --color-error-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */
--color-error-content: oklch(97% 0.004 15);
--radius-selector: 0.375rem; --color-surface: oklch(26% 0.012 45); /* elevated dark panel */
--radius-field: 0.5rem; --color-surface-content: oklch(88% 0.008 75); /* warm off-white — 9.2:1 on surface */
--radius-box: 0.875rem;
--size-selector: 0.25rem; --color-muted: oklch(78% 0.018 72); /* warm grey — 4.7:1 on base-100, 4.6:1 on surface */
--size-field: 0.25rem; --color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */
--border: 1px; --color-highlight-content: oklch(88% 0.008 75); /* warm off-white — 9.0:1 on highlight */
--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;
} }
/* ─────────────────────────────────────────
BIERGARTEN CASSIS
Dark. Blackberry base, cassis berry
primary, sour cherry secondary.
───────────────────────────────────────── */
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: "biergarten-cassis"; name: 'biergarten-cassis';
default: false; default: false;
prefersdark: false; prefersdark: false;
color-scheme: "dark"; color-scheme: 'dark';
/* Base — blackberry-stained barrel, dark purple-black */ --color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */
--color-base-100: oklch(13% 0.01 295); --color-base-200: oklch(17% 0.013 292); /* deep purple-black */
--color-base-200: oklch(17% 0.013 292); --color-base-300: oklch(22% 0.016 290); /* dark grape */
--color-base-300: oklch(22% 0.016 290); --color-base-content: oklch(90% 0.014 300); /* pale lavender-white — 10.7:1 on base-100 */
--color-base-content: oklch(90% 0.014 300);
/* Primary — cassis berry purple */ --color-primary: oklch(72% 0.075 295); /* cassis berry purple */
--color-primary: oklch(52% 0.07 295); --color-primary-content: oklch(95% 0.01 295); /* pale lavender — 4.5:1 on primary */
--color-primary-content: oklch(97% 0.008 295);
/* Secondary — sour cherry */ --color-secondary: oklch(68% 0.06 10); /* sour cherry rose */
--color-secondary: oklch(46% 0.05 10); --color-secondary-content: oklch(92% 0.006 10); /* warm near-white — 4.6:1 on secondary */
--color-secondary-content: oklch(97% 0.006 10);
/* Accent — tart lime zest */ --color-accent: oklch(75% 0.045 130); /* tart lime zest */
--color-accent: oklch(75% 0.045 130); --color-accent-content: oklch(98.5% 0.01 130); /* near-white — 4.8:1 on accent */
--color-accent-content: oklch(18% 0.04 130);
/* Neutral — deep blackened grape */ --color-neutral: oklch(18% 0.016 290); /* deep blackened grape */
--color-neutral: oklch(18% 0.016 290); --color-neutral-content: oklch(88% 0.01 295); /* pale lavender — 9.3:1 on neutral */
--color-neutral-content: oklch(88% 0.01 295);
/* Info — muted indigo */ --color-info: oklch(62% 0.04 250); /* muted indigo */
--color-info: oklch(46% 0.035 250); --color-info-content: oklch(88% 0.008 250); /* pale indigo-white — 4.8:1 on info */
--color-info-content: oklch(97% 0.006 250);
/* Success — dark elderberry green */ --color-success: oklch(65% 0.04 145); /* elderberry green */
--color-success: oklch(50% 0.035 145); --color-success-content: oklch(90% 0.008 145); /* pale green-white — 4.7:1 on success */
--color-success-content: oklch(97% 0.006 145);
/* Warning — sour apricot */ --color-warning: oklch(70% 0.05 65); /* sour apricot */
--color-warning: oklch(70% 0.05 65); --color-warning-content: oklch(97% 0.03 65); /* near-white — 5.6:1 on warning */
--color-warning-content: oklch(18% 0.03 65);
/* Error — kriek red */ --color-error: oklch(50% 0.055 22); /* kriek red */
--color-error: oklch(50% 0.055 22); --color-error-content: oklch(97% 0.006 22); /* near-white — 13.2:1 on error */
--color-error-content: oklch(97% 0.006 22);
--radius-selector: 0.5rem; --color-surface: oklch(27% 0.022 292); /* lifted purple-black panel */
--radius-field: 0.5rem; --color-surface-content: oklch(90% 0.014 300); /* pale lavender-white — 10.4:1 on surface */
--radius-box: 1rem;
--size-selector: 0.25rem; --color-muted: oklch(
--size-field: 0.25rem; 77.6% 0.022 300
--border: 1px; ); /* desaturated lavender — 4.6:1 on base-100, 4.5:1 on surface */
--depth: 1; --color-highlight: oklch(35% 0.04 295); /* cassis-tinted hover and active state */
--noise: 1; --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;
} }
/* ─────────────────────────────────────────
BIERGARTEN WEIZEN
Light. Near-white barley-green base,
fresh-cut barley primary, sage secondary.
───────────────────────────────────────── */
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: "biergarten-weizen"; name: 'biergarten-weizen';
default: false; default: false;
prefersdark: false; prefersdark: false;
color-scheme: "light"; color-scheme: 'light';
/* Base — near-white with the faintest young-barley green breath */ --color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */
--color-base-100: oklch(99% 0.007 112); --color-base-200: oklch(96% 0.012 114); /* pale barley wash */
--color-base-200: oklch(96% 0.012 114); --color-base-300: oklch(92% 0.019 116); /* light straw */
--color-base-300: oklch(92% 0.019 116); --color-base-content: oklch(20% 0.022 122); /* deep green-black — 19.5:1 on base-100 */
--color-base-content: oklch(20% 0.022 122);
/* Primary — fresh-cut barley, green-gold */ --color-primary: oklch(52% 0.085 118); /* fresh-cut barley green */
--color-primary: oklch(70% 0.09 118); --color-primary-content: oklch(97% 0.005 118); /* near-white — 12.5:1 on primary */
--color-primary-content: oklch(16% 0.022 118);
/* Secondary — muted sage stem */ --color-secondary: oklch(44% 0.055 128); /* muted sage stem */
--color-secondary: oklch(44% 0.055 128); --color-secondary-content: oklch(97% 0.005 128); /* near-white — 14.8:1 on secondary */
--color-secondary-content: oklch(97% 0.005 128);
/* Accent — pale morning dew */ --color-accent: oklch(93% 0.03 148); /* pale morning dew */
--color-accent: oklch(93% 0.03 148); --color-accent-content: oklch(22% 0.022 148); /* deep green — 13.4:1 on accent */
--color-accent-content: oklch(22% 0.022 148);
/* Neutral — dried straw with green memory */ --color-neutral: oklch(76% 0.028 118); /* dried straw, surface differentiation */
--color-neutral: oklch(88% 0.014 116); --color-neutral-content: oklch(98.9% 0.005 118); /* near-white — 4.6:1 on neutral */
--color-neutral-content: oklch(20% 0.02 118);
/* Info — clear summer sky */ --color-info: oklch(38% 0.065 232); /* clear summer sky */
--color-info: oklch(46% 0.07 232); --color-info-content: oklch(98% 0.005 232); /* near-white — 16.8:1 on info */
--color-info-content: oklch(98% 0.005 232);
/* Success — vivid young shoot */ --color-success: oklch(38% 0.085 145); /* young shoot green */
--color-success: oklch(48% 0.09 145); --color-success-content: oklch(98% 0.005 145); /* near-white — 16.8:1 on success */
--color-success-content: oklch(98% 0.005 145);
/* Warning — ripening grain amber */ --color-warning: oklch(68% 0.1 76); /* ripening grain amber */
--color-warning: oklch(68% 0.1 76); --color-warning-content: oklch(92.5% 0.005 72); /* warm near-white — 4.5:1 on warning */
--color-warning-content: oklch(18% 0.02 72);
/* Error — washed dusty rose */ --color-error: oklch(52% 0.1 18); /* dusty rose red */
--color-error: oklch(52% 0.1 18); --color-error-content: oklch(98% 0.005 15); /* near-white — 12.5:1 on error */
--color-error-content: oklch(98% 0.005 15);
--radius-selector: 2rem; --color-surface: oklch(94% 0.012 112); /* soft barley-wash panel */
--radius-field: 2rem; --color-surface-content: oklch(20% 0.022 122); /* deep green-black — 14.0:1 on surface */
--radius-box: 1rem;
--size-selector: 0.25rem; --color-muted: oklch(38% 0.055 120); /* sage green — 18.1:1 on base-100, 13.0:1 on surface */
--size-field: 0.25rem; --color-highlight: oklch(85% 0.04 118); /* green-tinted hover and active state */
--border: 1px; --color-highlight-content: oklch(20% 0.022 122); /* deep green-black — 7.8:1 on highlight */
--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;
} }

View File

@@ -1,138 +1,144 @@
import { import {
Disclosure, Disclosure,
DisclosureButton, DisclosureButton,
DisclosurePanel, DisclosurePanel,
Menu, Menu,
MenuButton, MenuButton,
MenuItem, MenuItem,
MenuItems, MenuItems,
} from "@headlessui/react"; } from '@headlessui/react';
import { Link } from "react-router"; import { Link } from 'react-router';
interface NavbarProps { interface NavbarProps {
auth: { auth: {
username: string; username: string;
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
userAccountId: string; userAccountId: string;
} | null; } | null;
} }
export default function Navbar({ auth }: NavbarProps) { export default function Navbar({ auth }: NavbarProps) {
const navLinks = [ const navLinks = [
{ to: "/theme", label: "Theme" }, { to: '/theme', label: 'Theme' },
{ to: "/beers", label: "Beers" }, { to: '/beers', label: 'Beers' },
{ to: "/breweries", label: "Breweries" }, { to: '/breweries', label: 'Breweries' },
{ to: "/beer-styles", label: "Beer Styles" }, { to: '/beer-styles', label: 'Beer Styles' },
]; ];
return ( return (
<Disclosure <Disclosure
as="nav" as="nav"
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md" className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
> >
{({ open }) => ( {({ open }) => (
<> <>
<div className="navbar mx-auto max-w-7xl px-2 sm:px-4"> <div className="navbar mx-auto max-w-7xl px-2 sm:px-4">
<div className="navbar-start gap-2"> <div className="navbar-start gap-2">
<DisclosureButton <DisclosureButton
className="btn btn-ghost btn-square lg:hidden" className="btn btn-ghost btn-square lg:hidden"
aria-label="Toggle navigation" aria-label="Toggle navigation"
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
className="h-5 w-5 stroke-current" className="h-5 w-5 stroke-current"
> >
{open ? ( {open ? (
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" strokeWidth="2"
d="M6 18L18 6M6 6l12 12" d="M6 18L18 6M6 6l12 12"
/> />
) : ( ) : (
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="2" strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16" d="M4 6h16M4 12h16M4 18h16"
/> />
)} )}
</svg> </svg>
</DisclosureButton> </DisclosureButton>
<Link to="/" className="text-xl font-bold"> <Link to="/" className="text-xl font-bold">
🍺 The Biergarten App 🍺 The Biergarten App
</Link> </Link>
</div> </div>
<div className="navbar-center hidden lg:flex gap-2"> <div className="navbar-center hidden lg:flex gap-2">
{navLinks.map((link) => ( {navLinks.map((link) => (
<Link key={link.to} to={link.to} className="btn btn-ghost btn-sm"> <Link key={link.to} to={link.to} className="btn btn-ghost btn-sm">
{link.label} {link.label}
</Link> </Link>
))} ))}
</div> </div>
<div className="navbar-end gap-2"> <div className="navbar-end gap-2">
{!auth && ( {!auth && (
<Link to="/register" className="btn btn-ghost btn-sm hidden sm:inline-flex"> <Link to="/register" className="btn btn-ghost btn-sm hidden sm:inline-flex">
Register User Register User
</Link> </Link>
)} )}
{auth ? ( {auth ? (
<> <>
<Link to="/dashboard" className="btn btn-primary btn-sm"> <Link to="/dashboard" className="btn btn-primary btn-sm">
Dashboard Dashboard
</Link> </Link>
<Menu as="div" className="relative"> <Menu as="div" className="relative">
<MenuButton className="btn btn-ghost btn-sm">{auth.username}</MenuButton> <MenuButton className="btn btn-ghost btn-sm">
<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"> {auth.username}
<MenuItem> </MenuButton>
{({ focus }) => ( <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">
<Link to="/dashboard" className={focus ? "active" : ""}> <MenuItem>
Dashboard {({ focus }) => (
</Link> <Link to="/dashboard" className={focus ? 'active' : ''}>
)} Dashboard
</MenuItem> </Link>
<MenuItem> )}
{({ focus }) => ( </MenuItem>
<Link to="/logout" className={focus ? "active" : ""}> <MenuItem>
Logout {({ focus }) => (
</Link> <Link to="/logout" className={focus ? 'active' : ''}>
)} Logout
</MenuItem> </Link>
</MenuItems> )}
</Menu> </MenuItem>
</> </MenuItems>
) : ( </Menu>
<Link to="/login" className="btn btn-primary btn-sm"> </>
Login ) : (
</Link> <Link to="/login" className="btn btn-primary btn-sm">
)} Login
</div> </Link>
</div> )}
</div>
</div>
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden"> <DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{navLinks.map((link) => ( {navLinks.map((link) => (
<Link key={link.to} to={link.to} className="btn btn-ghost btn-sm justify-start"> <Link
{link.label} key={link.to}
</Link> to={link.to}
))} className="btn btn-ghost btn-sm justify-start"
{!auth && ( >
<Link to="/register" className="btn btn-ghost btn-sm justify-start"> {link.label}
Register User </Link>
</Link> ))}
)} {!auth && (
</div> <Link to="/register" className="btn btn-ghost btn-sm justify-start">
</DisclosurePanel> Register User
</> </Link>
)} )}
</Disclosure> </div>
); </DisclosurePanel>
</>
)}
</Disclosure>
);
} }

View File

@@ -1,40 +1,40 @@
import { Description, Field, Label } from "@headlessui/react"; import { Description, Field, Label } from '@headlessui/react';
type FormFieldProps = React.InputHTMLAttributes<HTMLInputElement> & { type FormFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
label: string; label: string;
error?: string; error?: string;
hint?: string; hint?: string;
labelClassName?: string; labelClassName?: string;
inputClassName?: string; inputClassName?: string;
hintClassName?: string; hintClassName?: string;
}; };
export default function FormField({ export default function FormField({
label, label,
error, error,
hint, hint,
className, className,
labelClassName, labelClassName,
inputClassName, inputClassName,
hintClassName, hintClassName,
...inputProps ...inputProps
}: FormFieldProps) { }: FormFieldProps) {
return ( return (
<Field className={className ?? "space-y-1"}> <Field className={className ?? 'space-y-1'}>
<Label htmlFor={inputProps.id} className={labelClassName ?? "label font-medium"}> <Label htmlFor={inputProps.id} className={labelClassName ?? 'label font-medium'}>
{label} {label}
</Label> </Label>
<input <input
{...inputProps} {...inputProps}
className={inputClassName ?? `input w-full ${error ? "input-error" : ""}`} className={inputClassName ?? `input w-full ${error ? 'input-error' : ''}`}
/> />
{error ? ( {error ? (
<Description className={hintClassName ?? "label text-error"}>{error}</Description> <Description className={hintClassName ?? 'label text-error'}>{error}</Description>
) : hint ? ( ) : hint ? (
<Description className={hintClassName ?? "label"}>{hint}</Description> <Description className={hintClassName ?? 'label'}>{hint}</Description>
) : null} ) : null}
</Field> </Field>
); );
} }

View File

@@ -1,31 +1,31 @@
import { Button } from "@headlessui/react"; import { Button } from '@headlessui/react';
interface SubmitButtonProps { interface SubmitButtonProps {
isSubmitting: boolean; isSubmitting: boolean;
idleText: string; idleText: string;
submittingText: string; submittingText: string;
className?: string; className?: string;
} }
export default function SubmitButton({ export default function SubmitButton({
isSubmitting, isSubmitting,
idleText, idleText,
submittingText, submittingText,
className, className,
}: SubmitButtonProps) { }: SubmitButtonProps) {
return ( return (
<Button <Button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className={className ?? "btn btn-primary w-full mt-2"} className={className ?? 'btn btn-primary w-full mt-2'}
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
<span className="loading loading-spinner loading-sm" /> {submittingText} <span className="loading loading-spinner loading-sm" /> {submittingText}
</> </>
) : ( ) : (
idleText idleText
)} )}
</Button> </Button>
); );
} }

View File

@@ -1,25 +1,25 @@
import { Toaster } from "react-hot-toast"; import { Toaster } from 'react-hot-toast';
export default function ToastProvider() { export default function ToastProvider() {
return ( return (
<Toaster <Toaster
position="top-right" position="top-right"
toastOptions={{ toastOptions={{
duration: 3500, duration: 3500,
className: "rounded-box border border-base-300 bg-base-100 text-base-content shadow-lg", className: 'rounded-box border border-base-300 bg-base-100 text-base-content shadow-lg',
success: { success: {
iconTheme: { iconTheme: {
primary: "var(--color-success)", primary: 'var(--color-success)',
secondary: "var(--color-success-content)", secondary: 'var(--color-success-content)',
}, },
}, },
error: { error: {
iconTheme: { iconTheme: {
primary: "var(--color-error)", primary: 'var(--color-error)',
secondary: "var(--color-error-content)", secondary: 'var(--color-error-content)',
}, },
}, },
}} }}
/> />
); );
} }

View File

@@ -1,4 +1,4 @@
import toast from "react-hot-toast"; import toast from 'react-hot-toast';
export const showSuccessToast = (message: string) => toast.success(message); export const showSuccessToast = (message: string) => toast.success(message);
export const showErrorToast = (message: string) => toast.error(message); export const showErrorToast = (message: string) => toast.error(message);

View File

@@ -1,175 +1,162 @@
import { createCookieSessionStorage, redirect } from "react-router"; import { createCookieSessionStorage, redirect } from 'react-router';
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:8080"; const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
export interface AuthTokens { export interface AuthTokens {
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
userAccountId: string; userAccountId: string;
username: string; username: string;
} }
interface ApiResponse<T> { interface ApiResponse<T> {
message: string; message: string;
payload: T; payload: T;
} }
interface LoginPayload { interface LoginPayload {
userAccountId: string; userAccountId: string;
username: string; username: string;
refreshToken: string; refreshToken: string;
accessToken: string; accessToken: string;
} }
interface RegistrationPayload extends LoginPayload { interface RegistrationPayload extends LoginPayload {
confirmationEmailSent: boolean; confirmationEmailSent: boolean;
} }
const sessionStorage = createCookieSessionStorage({ const sessionStorage = createCookieSessionStorage({
cookie: { cookie: {
name: "__session", name: '__session',
httpOnly: true, httpOnly: true,
maxAge: 60 * 60 * 24 * 21, // 21 days (matches refresh token) maxAge: 60 * 60 * 24 * 21, // 21 days (matches refresh token)
path: "/", path: '/',
sameSite: "lax", sameSite: 'lax',
secrets: [process.env.SESSION_SECRET || "dev-secret-change-me"], secrets: [process.env.SESSION_SECRET || 'dev-secret-change-me'],
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === 'production',
}, },
}); });
export async function getSession(request: Request) { export async function getSession(request: Request) {
return sessionStorage.getSession(request.headers.get("Cookie")); return sessionStorage.getSession(request.headers.get('Cookie'));
} }
export async function commitSession( export async function commitSession(session: Awaited<ReturnType<typeof getSession>>) {
session: Awaited<ReturnType<typeof getSession>>, return sessionStorage.commitSession(session);
) {
return sessionStorage.commitSession(session);
} }
export async function destroySession( export async function destroySession(session: Awaited<ReturnType<typeof getSession>>) {
session: Awaited<ReturnType<typeof getSession>>, return sessionStorage.destroySession(session);
) {
return sessionStorage.destroySession(session);
} }
export async function requireAuth(request: Request): Promise<AuthTokens> { export async function requireAuth(request: Request): Promise<AuthTokens> {
const session = await getSession(request); const session = await getSession(request);
const accessToken = session.get("accessToken"); const accessToken = session.get('accessToken');
const refreshToken = session.get("refreshToken"); const refreshToken = session.get('refreshToken');
if (!accessToken || !refreshToken) { if (!accessToken || !refreshToken) {
throw redirect("/login"); throw redirect('/login');
} }
return { return {
accessToken, accessToken,
refreshToken, refreshToken,
userAccountId: session.get("userAccountId"), userAccountId: session.get('userAccountId'),
username: session.get("username"), username: session.get('username'),
}; };
} }
export async function getOptionalAuth( export async function getOptionalAuth(request: Request): Promise<AuthTokens | null> {
request: Request, const session = await getSession(request);
): Promise<AuthTokens | null> { const accessToken = session.get('accessToken');
const session = await getSession(request);
const accessToken = session.get("accessToken");
if (!accessToken) return null; if (!accessToken) return null;
return { return {
accessToken, accessToken,
refreshToken: session.get("refreshToken"), refreshToken: session.get('refreshToken'),
userAccountId: session.get("userAccountId"), userAccountId: session.get('userAccountId'),
username: session.get("username"), username: session.get('username'),
}; };
} }
export async function login(username: string, password: string) { export async function login(username: string, password: string) {
const res = await fetch(`${API_BASE_URL}/api/auth/login`, { const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: "POST", method: 'POST',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
throw new Error(text || `Login failed (${res.status})`); throw new Error(text || `Login failed (${res.status})`);
} }
const data: ApiResponse<LoginPayload> = await res.json(); const data: ApiResponse<LoginPayload> = await res.json();
return data.payload; return data.payload;
} }
export async function register(body: { export async function register(body: {
username: string; username: string;
firstName: string; firstName: string;
lastName: string; lastName: string;
email: string; email: string;
dateOfBirth: string; dateOfBirth: string;
password: string; password: string;
}) { }) {
const res = await fetch(`${API_BASE_URL}/api/auth/register`, { const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: "POST", method: 'POST',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
throw new Error(text || `Registration failed (${res.status})`); throw new Error(text || `Registration failed (${res.status})`);
} }
const data: ApiResponse<RegistrationPayload> = await res.json(); const data: ApiResponse<RegistrationPayload> = await res.json();
return data.payload; return data.payload;
} }
export async function refreshTokens(refreshToken: string) { export async function refreshTokens(refreshToken: string) {
const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, { const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, {
method: "POST", method: 'POST',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }), body: JSON.stringify({ refreshToken }),
}); });
if (!res.ok) { if (!res.ok) {
throw new Error("Token refresh failed"); throw new Error('Token refresh failed');
} }
const data: ApiResponse<LoginPayload> = await res.json(); const data: ApiResponse<LoginPayload> = await res.json();
return data.payload; return data.payload;
} }
export async function confirmEmail(token: string, accessToken: string) { export async function confirmEmail(token: string, accessToken: string) {
const res = await fetch( const res = await fetch(`${API_BASE_URL}/api/auth/confirm?token=${encodeURIComponent(token)}`, {
`${API_BASE_URL}/api/auth/confirm?token=${encodeURIComponent(token)}`, method: 'POST',
{
method: "POST",
headers: { Authorization: `Bearer ${accessToken}` }, headers: { Authorization: `Bearer ${accessToken}` },
}, });
);
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
throw new Error(text || `Confirmation failed (${res.status})`); throw new Error(text || `Confirmation failed (${res.status})`);
} }
const data: ApiResponse<{ userAccountId: string; confirmedDate: string }> = const data: ApiResponse<{ userAccountId: string; confirmedDate: string }> = await res.json();
await res.json(); return data.payload;
return data.payload;
} }
export async function createAuthSession( export async function createAuthSession(payload: LoginPayload, redirectTo: string) {
payload: LoginPayload, const session = await sessionStorage.getSession();
redirectTo: string, session.set('accessToken', payload.accessToken);
) { session.set('refreshToken', payload.refreshToken);
const session = await sessionStorage.getSession(); session.set('userAccountId', payload.userAccountId);
session.set("accessToken", payload.accessToken); session.set('username', payload.username);
session.set("refreshToken", payload.refreshToken);
session.set("userAccountId", payload.userAccountId);
session.set("username", payload.username);
return redirect(redirectTo, { return redirect(redirectTo, {
headers: { "Set-Cookie": await commitSession(session) }, headers: { 'Set-Cookie': await commitSession(session) },
}); });
} }

View File

@@ -1,33 +1,33 @@
import { z } from "zod"; import { z } from 'zod';
export const loginSchema = z.object({ export const loginSchema = z.object({
username: z.string().min(1, "Username is required"), username: z.string().min(1, 'Username is required'),
password: z.string().min(1, "Password is required"), password: z.string().min(1, 'Password is required'),
}); });
export type LoginSchema = z.infer<typeof loginSchema>; export type LoginSchema = z.infer<typeof loginSchema>;
export const registerSchema = z export const registerSchema = z
.object({ .object({
username: z username: z
.string() .string()
.min(3, "Username must be at least 3 characters") .min(3, 'Username must be at least 3 characters')
.max(20, "Username must be at most 20 characters"), .max(20, 'Username must be at most 20 characters'),
firstName: z.string().min(1, "First name is required"), firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, "Last name is required"), lastName: z.string().min(1, 'Last name is required'),
email: z.string().email("Invalid email address"), email: z.string().email('Invalid email address'),
dateOfBirth: z.string().min(1, "Date of birth is required"), dateOfBirth: z.string().min(1, 'Date of birth is required'),
password: z password: z
.string() .string()
.min(8, "Password must be at least 8 characters") .min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, "Password must contain an uppercase letter") .regex(/[A-Z]/, 'Password must contain an uppercase letter')
.regex(/[a-z]/, "Password must contain a lowercase letter") .regex(/[a-z]/, 'Password must contain a lowercase letter')
.regex(/[0-9]/, "Password must contain a number"), .regex(/[0-9]/, 'Password must contain a number'),
confirmPassword: z.string().min(1, "Please confirm your password"), confirmPassword: z.string().min(1, 'Please confirm your password'),
}) })
.refine((data) => data.password === data.confirmPassword, { .refine((data) => data.password === data.confirmPassword, {
message: "Passwords must match", message: 'Passwords must match',
path: ["confirmPassword"], path: ['confirmPassword'],
}); });
export type RegisterSchema = z.infer<typeof registerSchema>; export type RegisterSchema = z.infer<typeof registerSchema>;

View File

@@ -1,41 +1,41 @@
export type ThemeName = export type ThemeName =
| "biergarten-lager" | 'biergarten-lager'
| "biergarten-stout" | 'biergarten-stout'
| "biergarten-cassis" | 'biergarten-cassis'
| "biergarten-weizen"; | 'biergarten-weizen';
export interface ThemeOption { export interface ThemeOption {
value: ThemeName; value: ThemeName;
label: string; label: string;
vibe: string; vibe: string;
} }
export const defaultThemeName: ThemeName = "biergarten-lager"; export const defaultThemeName: ThemeName = 'biergarten-lager';
export const themeStorageKey = "biergarten-theme"; export const themeStorageKey = 'biergarten-theme';
export const biergartenThemes: ThemeOption[] = [ export const biergartenThemes: ThemeOption[] = [
{ {
value: "biergarten-lager", value: 'biergarten-lager',
label: "Biergarten Lager", label: 'Biergarten Lager',
vibe: "Muted parchment, mellow amber, daytime beer garden", vibe: 'Muted parchment, mellow amber, daytime beer garden',
}, },
{ {
value: "biergarten-stout", value: 'biergarten-stout',
label: "Biergarten Stout", label: 'Biergarten Stout',
vibe: "Charred barrel, deep roast, cozy evening cellar", vibe: 'Charred barrel, deep roast, cozy evening cellar',
}, },
{ {
value: "biergarten-cassis", value: 'biergarten-cassis',
label: "Biergarten Cassis", label: 'Biergarten Cassis',
vibe: "Blackberry barrel, sour berry dark, vivid night market", vibe: 'Blackberry barrel, sour berry dark, vivid night market',
}, },
{ {
value: "biergarten-weizen", value: 'biergarten-weizen',
label: "Biergarten Weizen", label: 'Biergarten Weizen',
vibe: "Ultra-light young barley, green undertone, bright spring afternoon", vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
}, },
]; ];
export function isBiergartenTheme(value: string | null | undefined): value is ThemeName { export function isBiergartenTheme(value: string | null | undefined): value is ThemeName {
return biergartenThemes.some((theme) => theme.value === value); return biergartenThemes.some((theme) => theme.value === value);
} }

View File

@@ -1,88 +1,90 @@
import { import {
isRouteErrorResponse, isRouteErrorResponse,
Links, Links,
Meta, Meta,
Outlet, Outlet,
Scripts, Scripts,
ScrollRestoration, ScrollRestoration,
} from "react-router"; } from 'react-router';
import type { Route } from "./+types/root"; import type { Route } from './+types/root';
import "./app.css"; import './app.css';
import Navbar from "./components/Navbar"; import Navbar from './components/Navbar';
import ToastProvider from "./components/toast/ToastProvider"; import ToastProvider from './components/toast/ToastProvider';
import { getOptionalAuth } from "./lib/auth.server"; import { getOptionalAuth } from './lib/auth.server';
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ {
rel: "preconnect", rel: 'preconnect',
href: "https://fonts.gstatic.com", href: 'https://fonts.gstatic.com',
crossOrigin: "anonymous", crossOrigin: 'anonymous',
}, },
{ {
rel: "stylesheet", 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", 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) => { export const loader = async ({ request }: Route.LoaderArgs) => {
const auth = await getOptionalAuth(request); const auth = await getOptionalAuth(request);
return { auth }; return { auth };
}; };
export function Layout({ children }: { children: React.ReactNode }) { export function Layout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta /> <Meta />
<Links /> <Links />
</head> </head>
<body> <body>
{children} {children}
<ScrollRestoration /> <ScrollRestoration />
<Scripts /> <Scripts />
</body> </body>
</html> </html>
); );
} }
export default function App({ loaderData }: Route.ComponentProps) { export default function App({ loaderData }: Route.ComponentProps) {
const { auth } = loaderData; const { auth } = loaderData;
return ( return (
<> <>
<Navbar auth={auth} /> <Navbar auth={auth} />
<ToastProvider /> <ToastProvider />
<Outlet /> <Outlet />
</> </>
); );
} }
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!"; let message = 'Oops!';
let details = "An unexpected error occurred."; let details = 'An unexpected error occurred.';
let stack: string | undefined; let stack: string | undefined;
if (isRouteErrorResponse(error)) { if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error"; message = error.status === 404 ? '404' : 'Error';
details = details =
error.status === 404 ? "The requested page could not be found." : error.statusText || details; error.status === 404
} else if (import.meta.env.DEV && error && error instanceof Error) { ? 'The requested page could not be found.'
details = error.message; : error.statusText || details;
stack = error.stack; } else if (import.meta.env.DEV && error && error instanceof Error) {
} details = error.message;
stack = error.stack;
}
return ( return (
<main className="pt-16 p-4 container mx-auto"> <main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1> <h1>{message}</h1>
<p>{details}</p> <p>{details}</p>
{stack && ( {stack && (
<pre className="w-full p-4 overflow-x-auto"> <pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code> <code>{stack}</code>
</pre> </pre>
)} )}
</main> </main>
); );
} }

View File

@@ -1,14 +1,14 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes"; import { type RouteConfig, index, route } from '@react-router/dev/routes';
export default [ export default [
index("routes/home.tsx"), index('routes/home.tsx'),
route("theme", "routes/theme.tsx"), route('theme', 'routes/theme.tsx'),
route("login", "routes/login.tsx"), route('login', 'routes/login.tsx'),
route("register", "routes/register.tsx"), route('register', 'routes/register.tsx'),
route("logout", "routes/logout.tsx"), route('logout', 'routes/logout.tsx'),
route("dashboard", "routes/dashboard.tsx"), route('dashboard', 'routes/dashboard.tsx'),
route("confirm", "routes/confirm.tsx"), route('confirm', 'routes/confirm.tsx'),
route("beers", "routes/beers.tsx"), route('beers', 'routes/beers.tsx'),
route("breweries", "routes/breweries.tsx"), route('breweries', 'routes/breweries.tsx'),
route("beer-styles", "routes/beer-styles.tsx"), route('beer-styles', 'routes/beer-styles.tsx'),
] satisfies RouteConfig; ] satisfies RouteConfig;

View File

@@ -1,18 +1,16 @@
import type { Route } from "./+types/beer-styles"; import type { Route } from './+types/beer-styles';
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [{ title: "Beer Styles | The Biergarten App" }]; return [{ title: 'Beer Styles | The Biergarten App' }];
} }
export default function BeerStyles() { export default function BeerStyles() {
return ( return (
<div className="min-h-screen bg-base-200"> <div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4"> <div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-4">Beer Styles</h1> <h1 className="text-4xl font-bold mb-4">Beer Styles</h1>
<p className="text-base-content/70"> <p className="text-base-content/70">Learn about different beer styles.</p>
Learn about different beer styles. </div>
</p>
</div> </div>
</div> );
);
} }

View File

@@ -1,16 +1,16 @@
import type { Route } from "./+types/beers"; import type { Route } from './+types/beers';
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [{ title: "Beers | The Biergarten App" }]; return [{ title: 'Beers | The Biergarten App' }];
} }
export default function Beers() { export default function Beers() {
return ( return (
<div className="min-h-screen bg-base-200"> <div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4"> <div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-4">Beers</h1> <h1 className="text-4xl font-bold mb-4">Beers</h1>
<p className="text-base-content/70">Explore our collection of beers.</p> <p className="text-base-content/70">Explore our collection of beers.</p>
</div>
</div> </div>
</div> );
);
} }

View File

@@ -1,16 +1,16 @@
import type { Route } from "./+types/breweries"; import type { Route } from './+types/breweries';
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [{ title: "Breweries | The Biergarten App" }]; return [{ title: 'Breweries | The Biergarten App' }];
} }
export default function Breweries() { export default function Breweries() {
return ( return (
<div className="min-h-screen bg-base-200"> <div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4"> <div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-4">Breweries</h1> <h1 className="text-4xl font-bold mb-4">Breweries</h1>
<p className="text-base-content/70">Discover our partner breweries.</p> <p className="text-base-content/70">Discover our partner breweries.</p>
</div>
</div> </div>
</div> );
);
} }

View File

@@ -1,90 +1,91 @@
import { useEffect } from "react"; import { useEffect } from 'react';
import { Link } from "react-router"; import { Link } from 'react-router';
import { showErrorToast, showSuccessToast } from "../components/toast/toast"; import { showErrorToast, showSuccessToast } from '../components/toast/toast';
import { confirmEmail, requireAuth } from "../lib/auth.server"; import { confirmEmail, requireAuth } from '../lib/auth.server';
import type { Route } from "./+types/confirm"; import type { Route } from './+types/confirm';
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [{ title: "Confirm Email | The Biergarten App" }]; return [{ title: 'Confirm Email | The Biergarten App' }];
} }
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) {
const auth = await requireAuth(request); const auth = await requireAuth(request);
const url = new URL(request.url); const url = new URL(request.url);
const token = url.searchParams.get("token"); const token = url.searchParams.get('token');
if (!token) { if (!token) {
return { success: false as const, error: "Missing confirmation token." }; return { success: false as const, error: 'Missing confirmation token.' };
} }
try { try {
const payload = await confirmEmail(token, auth.accessToken); const payload = await confirmEmail(token, auth.accessToken);
return { return {
success: true as const, success: true as const,
confirmedDate: payload.confirmedDate, confirmedDate: payload.confirmedDate,
}; };
} catch (err) { } catch (err) {
return { return {
success: false as const, success: false as const,
error: err instanceof Error ? err.message : "Confirmation failed.", error: err instanceof Error ? err.message : 'Confirmation failed.',
}; };
} }
} }
export default function Confirm({ loaderData }: Route.ComponentProps) { export default function Confirm({ loaderData }: Route.ComponentProps) {
useEffect(() => { useEffect(() => {
if (loaderData.success) { if (loaderData.success) {
showSuccessToast("Email confirmed successfully."); showSuccessToast('Email confirmed successfully.');
return; return;
} }
showErrorToast(loaderData.error); showErrorToast(loaderData.error);
}, [loaderData]); }, [loaderData]);
return ( return (
<div className="hero min-h-screen bg-base-200"> <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 w-full max-w-md bg-base-100 shadow-xl">
<div className="card-body items-center text-center gap-4"> <div className="card-body items-center text-center gap-4">
{loaderData.success ? ( {loaderData.success ? (
<> <>
<div className="text-success text-6xl"></div> <div className="text-success text-6xl"></div>
<h1 className="card-title text-2xl">Email Confirmed!</h1> <h1 className="card-title text-2xl">Email Confirmed!</h1>
<p className="text-base-content/70"> <p className="text-base-content/70">
Your email address has been successfully verified. Your email address has been successfully verified.
</p> </p>
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left"> <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"> <span className="text-base-content/50 text-xs uppercase tracking-widest font-semibold">
Confirmed at Confirmed at
</span> </span>
<p className="font-mono mt-1"> <p className="font-mono mt-1">
{new Date(loaderData.confirmedDate).toLocaleString()} {new Date(loaderData.confirmedDate).toLocaleString()}
</p> </p>
</div> </div>
<div className="card-actions w-full pt-2"> <div className="card-actions w-full pt-2">
<Link to="/dashboard" className="btn btn-primary w-full"> <Link to="/dashboard" className="btn btn-primary w-full">
Go to Dashboard Go to Dashboard
</Link> </Link>
</div> </div>
</> </>
) : ( ) : (
<> <>
<div className="text-error text-6xl"></div> <div className="text-error text-6xl"></div>
<h1 className="card-title text-2xl">Confirmation Failed</h1> <h1 className="card-title text-2xl">Confirmation Failed</h1>
<div role="alert" className="alert alert-error alert-soft w-full"> <div role="alert" className="alert alert-error alert-soft w-full">
<span>{loaderData.error}</span> <span>{loaderData.error}</span>
</div> </div>
<p className="text-base-content/70 text-sm"> <p className="text-base-content/70 text-sm">
The confirmation link may have expired (valid for 30 minutes) or already been used. The confirmation link may have expired (valid for 30 minutes) or already
</p> been used.
<div className="card-actions w-full pt-2 flex-col gap-2"> </p>
<Link to="/dashboard" className="btn btn-primary w-full"> <div className="card-actions w-full pt-2 flex-col gap-2">
Back to Dashboard <Link to="/dashboard" className="btn btn-primary w-full">
</Link> Back to Dashboard
</div> </Link>
</> </div>
)} </>
</div> )}
</div>
</div>
</div> </div>
</div> );
);
} }

View File

@@ -1,110 +1,105 @@
import { requireAuth } from "../lib/auth.server"; import { requireAuth } from '../lib/auth.server';
import type { Route } from "./+types/dashboard"; import type { Route } from './+types/dashboard';
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [{ title: "Dashboard | The Biergarten App" }]; return [{ title: 'Dashboard | The Biergarten App' }];
} }
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) {
const auth = await requireAuth(request); const auth = await requireAuth(request);
return { return {
username: auth.username, username: auth.username,
userAccountId: auth.userAccountId, userAccountId: auth.userAccountId,
}; };
} }
export default function Dashboard({ loaderData }: Route.ComponentProps) { export default function Dashboard({ loaderData }: Route.ComponentProps) {
const { username, userAccountId } = loaderData; const { username, userAccountId } = loaderData;
return ( return (
<div className="min-h-screen bg-base-200"> <div className="min-h-screen bg-base-200">
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6"> <div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
<div className="card bg-base-100 shadow"> <div className="card bg-base-100 shadow">
<div className="card-body"> <div className="card-body">
<h2 className="card-title text-2xl">Welcome, {username}!</h2> <h2 className="card-title text-2xl">Welcome, {username}!</h2>
<p className="text-base-content/70"> <p className="text-base-content/70">
You are successfully authenticated. This is a protected page that You are successfully authenticated. This is a protected page that requires a
requires a valid session. valid session.
</p> </p>
<div className="bg-base-200 rounded-box p-4 mt-2"> <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"> <p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
Session Info Session Info
</p> </p>
<div className="stats stats-vertical w-full"> <div className="stats stats-vertical w-full">
<div className="stat py-2"> <div className="stat py-2">
<div className="stat-title">Username</div> <div className="stat-title">Username</div>
<div className="stat-value text-lg font-mono">{username}</div> <div className="stat-value text-lg font-mono">{username}</div>
</div> </div>
<div className="stat py-2"> <div className="stat py-2">
<div className="stat-title">User ID</div> <div className="stat-title">User ID</div>
<div className="stat-desc font-mono text-xs mt-1"> <div className="stat-desc font-mono text-xs mt-1">{userAccountId}</div>
{userAccountId} </div>
</div>
</div> </div>
</div> </div>
</div>
</div> </div>
</div>
</div>
<div className="card bg-base-100 shadow"> <div className="card bg-base-100 shadow">
<div className="card-body"> <div className="card-body">
<h2 className="card-title">Auth Flow Demo</h2> <h2 className="card-title">Auth Flow Demo</h2>
<p className="text-sm text-base-content/70"> <p className="text-sm text-base-content/70">
This demo showcases the following authentication features: 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 &amp; password
</p> </p>
</div> <ul className="list">
</li> <li className="list-row">
<li className="list-row"> <div>
<div> <p className="font-semibold">Login</p>
<p className="font-semibold">Register</p> <p className="text-sm text-base-content/60">
<p className="text-sm text-base-content/60"> POST to <code className="kbd kbd-sm">/api/auth/login</code> with
POST to{" "} username &amp; password
<code className="kbd kbd-sm">/api/auth/register</code> with </p>
full user details </div>
</p> </li>
</div> <li className="list-row">
</li> <div>
<li className="list-row"> <p className="font-semibold">Register</p>
<div> <p className="text-sm text-base-content/60">
<p className="font-semibold">Session</p> POST to <code className="kbd kbd-sm">/api/auth/register</code> with
<p className="text-sm text-base-content/60"> full user details
JWT access &amp; refresh tokens stored in an HTTP-only </p>
cookie </div>
</p> </li>
</div> <li className="list-row">
</li> <div>
<li className="list-row"> <p className="font-semibold">Session</p>
<div> <p className="text-sm text-base-content/60">
<p className="font-semibold">Protected Routes</p> JWT access &amp; refresh tokens stored in an HTTP-only cookie
<p className="text-sm text-base-content/60"> </p>
This dashboard requires authentication via{" "} </div>
<code className="kbd kbd-sm">requireAuth()</code> </li>
</p> <li className="list-row">
</div> <div>
</li> <p className="font-semibold">Protected Routes</p>
<li className="list-row"> <p className="text-sm text-base-content/60">
<div> This dashboard requires authentication via{' '}
<p className="font-semibold">Token Refresh</p> <code className="kbd kbd-sm">requireAuth()</code>
<p className="text-sm text-base-content/60"> </p>
POST to{" "} </div>
<code className="kbd kbd-sm">/api/auth/refresh</code> with </li>
refresh token <li className="list-row">
</p> <div>
</div> <p className="font-semibold">Token Refresh</p>
</li> <p className="text-sm text-base-content/60">
</ul> POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with
</div> refresh token
</div> </p>
</div>
</li>
</ul>
</div>
</div>
</div>
</div> </div>
</div> );
);
} }

View File

@@ -1,56 +1,56 @@
import { Link } from "react-router"; import { Link } from 'react-router';
import { getOptionalAuth } from "../lib/auth.server"; import { getOptionalAuth } from '../lib/auth.server';
import type { Route } from "./+types/home"; import type { Route } from './+types/home';
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [ return [
{ title: "The Biergarten App" }, { title: 'The Biergarten App' },
{ name: "description", content: "Welcome to The Biergarten App" }, { name: 'description', content: 'Welcome to The Biergarten App' },
]; ];
} }
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) {
const auth = await getOptionalAuth(request); const auth = await getOptionalAuth(request);
return { username: auth?.username ?? null }; return { username: auth?.username ?? null };
} }
export default function Home({ loaderData }: Route.ComponentProps) { export default function Home({ loaderData }: Route.ComponentProps) {
const { username } = loaderData; const { username } = loaderData;
return ( return (
<div className="hero min-h-screen bg-base-200"> <div className="hero min-h-screen bg-base-200">
<div className="hero-content text-center"> <div className="hero-content text-center">
<div className="max-w-md space-y-6"> <div className="max-w-md space-y-6">
<h1 className="text-5xl font-bold">🍺 The Biergarten App</h1> <h1 className="text-5xl font-bold">🍺 The Biergarten App</h1>
<p className="text-lg text-base-content/70">Authentication Demo</p> <p className="text-lg text-base-content/70">Authentication Demo</p>
{username ? ( {username ? (
<> <>
<p className="text-base-content/80"> <p className="text-base-content/80">
Welcome back,{" "} Welcome back, <span className="font-semibold text-primary">{username}</span>
<span className="font-semibold text-primary">{username}</span>! !
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Link to="/dashboard" className="btn btn-primary"> <Link to="/dashboard" className="btn btn-primary">
Dashboard Dashboard
</Link> </Link>
<Link to="/logout" className="btn btn-ghost"> <Link to="/logout" className="btn btn-ghost">
Logout Logout
</Link> </Link>
</div> </div>
</> </>
) : ( ) : (
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Link to="/login" className="btn btn-primary"> <Link to="/login" className="btn btn-primary">
Login Login
</Link> </Link>
<Link to="/register" className="btn btn-outline"> <Link to="/register" className="btn btn-outline">
Register Register
</Link> </Link>
</div>
)}
</div> </div>
)} </div>
</div>
</div> </div>
</div> );
);
} }

View File

@@ -1,128 +1,128 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from '@hookform/resolvers/zod';
import { HomeSimpleDoor, LogIn, UserPlus } from "iconoir-react"; import { HomeSimpleDoor, LogIn, UserPlus } from 'iconoir-react';
import { useEffect } from "react"; import { useEffect } from 'react';
import { useForm } from "react-hook-form"; import { useForm } from 'react-hook-form';
import { Link, redirect, useNavigation, useSubmit } from "react-router"; import { Link, redirect, useNavigation, useSubmit } from 'react-router';
import FormField from "../components/forms/FormField"; import FormField from '../components/forms/FormField';
import SubmitButton from "../components/forms/SubmitButton"; import SubmitButton from '../components/forms/SubmitButton';
import { showErrorToast } from "../components/toast/toast"; import { showErrorToast } from '../components/toast/toast';
import { createAuthSession, getOptionalAuth, login } from "../lib/auth.server"; import { createAuthSession, getOptionalAuth, login } from '../lib/auth.server';
import { loginSchema, type LoginSchema } from "../lib/schemas"; import { loginSchema, type LoginSchema } from '../lib/schemas';
import type { Route } from "./+types/login"; import type { Route } from './+types/login';
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [{ title: "Login | The Biergarten App" }]; return [{ title: 'Login | The Biergarten App' }];
} }
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) {
const auth = await getOptionalAuth(request); const auth = await getOptionalAuth(request);
if (auth) throw redirect("/dashboard"); if (auth) throw redirect('/dashboard');
return null; return null;
} }
export async function action({ request }: Route.ActionArgs) { export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData(); const formData = await request.formData();
const result = loginSchema.safeParse({ const result = loginSchema.safeParse({
username: formData.get("username"), username: formData.get('username'),
password: formData.get("password"), password: formData.get('password'),
}); });
if (!result.success) { if (!result.success) {
return { error: result.error.issues[0].message }; return { error: result.error.issues[0].message };
} }
try { try {
const payload = await login(result.data.username, result.data.password); const payload = await login(result.data.username, result.data.password);
return createAuthSession(payload, "/dashboard"); return createAuthSession(payload, '/dashboard');
} catch (err) { } catch (err) {
return { error: err instanceof Error ? err.message : "Login failed." }; return { error: err instanceof Error ? err.message : 'Login failed.' };
} }
} }
export default function Login({ actionData }: Route.ComponentProps) { export default function Login({ actionData }: Route.ComponentProps) {
const navigation = useNavigation(); const navigation = useNavigation();
const submit = useSubmit(); const submit = useSubmit();
const isSubmitting = navigation.state === "submitting"; const isSubmitting = navigation.state === 'submitting';
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
} = useForm<LoginSchema>({ resolver: zodResolver(loginSchema) }); } = useForm<LoginSchema>({ resolver: zodResolver(loginSchema) });
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
submit(data, { method: "post" }); submit(data, { method: 'post' });
}); });
useEffect(() => { useEffect(() => {
if (actionData?.error) { if (actionData?.error) {
showErrorToast(actionData.error); showErrorToast(actionData.error);
} }
}, [actionData?.error]); }, [actionData?.error]);
return ( return (
<div className="hero min-h-screen bg-base-200"> <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 w-full max-w-md bg-base-100 shadow-xl">
<div className="card-body gap-4"> <div className="card-body gap-4">
<div className="text-center"> <div className="text-center">
<h1 className="card-title text-3xl justify-center gap-2"> <h1 className="card-title text-3xl justify-center gap-2">
<LogIn className="size-7" aria-hidden="true" /> <LogIn className="size-7" aria-hidden="true" />
Login Login
</h1> </h1>
<p className="text-base-content/70">Sign in to your Biergarten account</p> <p className="text-base-content/70">Sign in to your Biergarten account</p>
</div> </div>
{actionData?.error && ( {actionData?.error && (
<div role="alert" className="alert alert-error alert-soft"> <div role="alert" className="alert alert-error alert-soft">
<span>{actionData.error}</span> <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>
<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>
</div> );
);
} }

View File

@@ -1,10 +1,10 @@
import { redirect } from "react-router"; import { redirect } from 'react-router';
import { destroySession, getSession } from "../lib/auth.server"; import { destroySession, getSession } from '../lib/auth.server';
import type { Route } from "./+types/logout"; import type { Route } from './+types/logout';
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) {
const session = await getSession(request); const session = await getSession(request);
return redirect("/", { return redirect('/', {
headers: { "Set-Cookie": await destroySession(session) }, headers: { 'Set-Cookie': await destroySession(session) },
}); });
} }

View File

@@ -1,189 +1,189 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from '@hookform/resolvers/zod';
import { useEffect } from "react"; import { useEffect } from 'react';
import { useForm } from "react-hook-form"; import { useForm } from 'react-hook-form';
import { Link, redirect, useNavigation, useSubmit } from "react-router"; import { Link, redirect, useNavigation, useSubmit } from 'react-router';
import FormField from "../components/forms/FormField"; import FormField from '../components/forms/FormField';
import SubmitButton from "../components/forms/SubmitButton"; import SubmitButton from '../components/forms/SubmitButton';
import { showErrorToast } from "../components/toast/toast"; import { showErrorToast } from '../components/toast/toast';
import { createAuthSession, getOptionalAuth, register } from "../lib/auth.server"; import { createAuthSession, getOptionalAuth, register } from '../lib/auth.server';
import { registerSchema, type RegisterSchema } from "../lib/schemas"; import { registerSchema, type RegisterSchema } from '../lib/schemas';
import type { Route } from "./+types/register"; import type { Route } from './+types/register';
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [{ title: "Register | The Biergarten App" }]; return [{ title: 'Register | The Biergarten App' }];
} }
export async function loader({ request }: Route.LoaderArgs) { export async function loader({ request }: Route.LoaderArgs) {
const auth = await getOptionalAuth(request); const auth = await getOptionalAuth(request);
if (auth) throw redirect("/dashboard"); if (auth) throw redirect('/dashboard');
return null; return null;
} }
export async function action({ request }: Route.ActionArgs) { export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData(); const formData = await request.formData();
const result = registerSchema.safeParse({ const result = registerSchema.safeParse({
username: formData.get("username"), username: formData.get('username'),
firstName: formData.get("firstName"), firstName: formData.get('firstName'),
lastName: formData.get("lastName"), lastName: formData.get('lastName'),
email: formData.get("email"), email: formData.get('email'),
dateOfBirth: formData.get("dateOfBirth"), dateOfBirth: formData.get('dateOfBirth'),
password: formData.get("password"), password: formData.get('password'),
confirmPassword: formData.get("confirmPassword"), confirmPassword: formData.get('confirmPassword'),
}); });
if (!result.success) { if (!result.success) {
const fieldErrors = result.error.flatten().fieldErrors; const fieldErrors = result.error.flatten().fieldErrors;
return { error: null, fieldErrors }; return { error: null, fieldErrors };
} }
try { try {
const body = { const body = {
username: result.data.username, username: result.data.username,
firstName: result.data.firstName, firstName: result.data.firstName,
lastName: result.data.lastName, lastName: result.data.lastName,
email: result.data.email, email: result.data.email,
dateOfBirth: result.data.dateOfBirth, dateOfBirth: result.data.dateOfBirth,
password: result.data.password, password: result.data.password,
}; };
const payload = await register(body); const payload = await register(body);
return createAuthSession(payload, "/dashboard"); return createAuthSession(payload, '/dashboard');
} catch (err) { } catch (err) {
return { return {
error: err instanceof Error ? err.message : "Registration failed.", error: err instanceof Error ? err.message : 'Registration failed.',
fieldErrors: null, fieldErrors: null,
}; };
} }
} }
export default function Register({ actionData }: Route.ComponentProps) { export default function Register({ actionData }: Route.ComponentProps) {
const navigation = useNavigation(); const navigation = useNavigation();
const submit = useSubmit(); const submit = useSubmit();
const isSubmitting = navigation.state === "submitting"; const isSubmitting = navigation.state === 'submitting';
const { const {
register: field, register: field,
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
} = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) }); } = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
const onSubmit = handleSubmit((data) => { const onSubmit = handleSubmit((data) => {
submit(data, { method: "post" }); submit(data, { method: 'post' });
}); });
useEffect(() => { useEffect(() => {
if (actionData?.error) { if (actionData?.error) {
showErrorToast(actionData.error); showErrorToast(actionData.error);
} }
}, [actionData?.error]); }, [actionData?.error]);
return ( return (
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4"> <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 w-full max-w-lg bg-base-100 shadow-xl">
<div className="card-body gap-4"> <div className="card-body gap-4">
<div className="text-center"> <div className="text-center">
<h1 className="card-title text-3xl justify-center">Register</h1> <h1 className="card-title text-3xl justify-center">Register</h1>
<p className="text-base-content/70">Create your Biergarten account</p> <p className="text-base-content/70">Create your Biergarten account</p>
</div> </div>
{actionData?.error && ( {actionData?.error && (
<div role="alert" className="alert alert-error alert-soft"> <div role="alert" className="alert alert-error alert-soft">
<span>{actionData.error}</span> <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>
<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>
</div> );
);
} }

View File

@@ -1,158 +1,169 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import { import {
biergartenThemes, biergartenThemes,
defaultThemeName, defaultThemeName,
isBiergartenTheme, isBiergartenTheme,
themeStorageKey, themeStorageKey,
type ThemeName, type ThemeName,
} from "../lib/themes"; } from '../lib/themes';
import type { Route } from "./+types/theme"; import type { Route } from './+types/theme';
export function meta({}: Route.MetaArgs) { export function meta({}: Route.MetaArgs) {
return [ return [
{ title: "Theme | The Biergarten App" }, { title: 'Theme | The Biergarten App' },
{ {
name: "description", name: 'description',
content: "Theme guide and switcher for The Biergarten App", content: 'Theme guide and switcher for The Biergarten App',
}, },
]; ];
} }
function applyTheme(theme: ThemeName) { function applyTheme(theme: ThemeName) {
document.documentElement.setAttribute("data-theme", theme); document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(themeStorageKey, theme); localStorage.setItem(themeStorageKey, theme);
} }
export default function ThemePage() { export default function ThemePage() {
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(() => { const [selectedTheme, setSelectedTheme] = useState<ThemeName>(() => {
if (typeof window === "undefined") { if (typeof window === 'undefined') {
return defaultThemeName; return defaultThemeName;
} }
const savedTheme = localStorage.getItem(themeStorageKey); const savedTheme = localStorage.getItem(themeStorageKey);
return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName; return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
}); });
useEffect(() => { useEffect(() => {
applyTheme(selectedTheme); applyTheme(selectedTheme);
}, [selectedTheme]); }, [selectedTheme]);
const activeTheme = const activeTheme =
biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0]; biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];
return ( return (
<main className="min-h-screen bg-base-200 px-4 py-8 sm:px-6 lg:px-8"> <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"> <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"> <section className="card border border-base-300 bg-base-100 shadow-xl">
<div className="card-body gap-4"> <div className="card-body gap-4">
<h1 className="card-title text-3xl sm:text-4xl">Theme Guide</h1> <h1 className="card-title text-3xl sm:text-4xl">Theme Guide</h1>
<p className="text-base-content/70"> <p className="text-base-content/70">
Four themes, four moods from the sun-bleached clarity of a Weizen afternoon to the Four themes, four moods from the sun-bleached clarity of a Weizen afternoon
deep berry dark of a Cassis barrel. Every theme shares the same semantic token to the deep berry dark of a Cassis barrel. Every theme shares the same semantic
structure so components stay consistent while the atmosphere shifts completely. token structure so components stay consistent while the atmosphere shifts
</p> completely.
<div className="alert alert-info alert-soft"> </p>
<span> <div className="alert alert-info alert-soft">
Active theme: <strong>{activeTheme.label}</strong> {activeTheme.vibe} <span>
</span> Active theme: <strong>{activeTheme.label}</strong> {activeTheme.vibe}
</div> </span>
</div> </div>
</section> </div>
</section>
<section className="card border border-base-300 bg-base-100 shadow-xl"> <section className="card border border-base-300 bg-base-100 shadow-xl">
<div className="card-body gap-4"> <div className="card-body gap-4">
<h2 className="card-title text-2xl">Theme switcher</h2> <h2 className="card-title text-2xl">Theme switcher</h2>
<p className="text-base-content/70">Pick a theme and preview it immediately.</p> <p className="text-base-content/70">Pick a theme and preview it immediately.</p>
<div <div
className="join join-vertical sm:join-horizontal" className="join join-vertical sm:join-horizontal"
role="radiogroup" role="radiogroup"
aria-label="Theme selector" 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 {biergartenThemes.map((theme) => {
type="radio" const checked = selectedTheme === theme.value;
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"> return (
<div className="card border border-base-300 bg-base-100 shadow-lg"> <label
<div className="card-body"> key={theme.value}
<h3 className="card-title">Brand colors</h3> className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
<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> <input
<div className="rounded-box bg-secondary p-3 text-secondary-content">Secondary</div> type="radio"
<div className="rounded-box bg-accent p-3 text-accent-content">Accent</div> name="theme"
<div className="rounded-box bg-neutral p-3 text-neutral-content">Neutral</div> value={theme.value}
</div> className="sr-only"
</div> checked={checked}
</div> onChange={() => {
setSelectedTheme(theme.value);
applyTheme(theme.value);
}}
/>
{theme.label}
</label>
);
})}
</div>
</div>
</section>
<div className="card border border-base-300 bg-base-100 shadow-lg"> <section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="card-body"> <div className="card border border-base-300 bg-base-100 shadow-lg">
<h3 className="card-title">Status colors</h3> <div className="card-body">
<div className="space-y-2 text-sm font-medium"> <h3 className="card-title">Brand colors</h3>
<div className="rounded-box bg-info p-3 text-info-content">Info</div> <div className="grid grid-cols-2 gap-2 text-sm font-medium">
<div className="rounded-box bg-success p-3 text-success-content">Success</div> <div className="rounded-box bg-primary p-3 text-primary-content">
<div className="rounded-box bg-warning p-3 text-warning-content">Warning</div> Primary
<div className="rounded-box bg-error p-3 text-error-content">Error</div> </div>
</div> <div className="rounded-box bg-secondary p-3 text-secondary-content">
</div> Secondary
</div> </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 md:col-span-2 xl:col-span-1"> <div className="card border border-base-300 bg-base-100 shadow-lg">
<div className="card-body"> <div className="card-body">
<h3 className="card-title">Core style outline</h3> <h3 className="card-title">Status colors</h3>
<ul className="list list-disc space-y-2 pl-5 text-base-content/80"> <div className="space-y-2 text-sm font-medium">
<li>Warm serif headings paired with clear sans-serif body text</li> <div className="rounded-box bg-info p-3 text-info-content">Info</div>
<li>Rounded, tactile surfaces with subtle depth and grain</li> <div className="rounded-box bg-success p-3 text-success-content">
<li>Semantic token usage to keep contrast consistent in both themes</li> Success
</ul> </div>
</div> <div className="rounded-box bg-warning p-3 text-warning-content">
</div> Warning
</section> </div>
<div className="rounded-box bg-error p-3 text-error-content">Error</div>
</div>
</div>
</div>
<section className="card border border-base-300 bg-base-100 shadow-xl"> <div className="card border border-base-300 bg-base-100 shadow-lg md:col-span-2 xl:col-span-1">
<div className="card-body gap-4"> <div className="card-body">
<h2 className="card-title text-2xl">Component preview</h2> <h3 className="card-title">Core style outline</h3>
<div className="flex flex-wrap gap-3"> <ul className="list list-disc space-y-2 pl-5 text-base-content/80">
<button className="btn btn-primary">Primary action</button> <li>Warm serif headings paired with clear sans-serif body text</li>
<button className="btn btn-secondary">Secondary action</button> <li>Rounded, tactile surfaces with subtle depth and grain</li>
<button className="btn btn-accent">Accent action</button> <li>Semantic token usage to keep contrast consistent in both themes</li>
<button className="btn btn-ghost">Ghost action</button> </ul>
</div> </div>
<div className="grid gap-3 md:grid-cols-2"> </div>
<div role="alert" className="alert alert-success alert-soft"> </section>
<span>Theme tokens are applied consistently.</span>
</div> <section className="card border border-base-300 bg-base-100 shadow-xl">
<div role="alert" className="alert alert-warning alert-soft"> <div className="card-body gap-4">
<span>Use semantic colors over hard-coded color values.</span> <h2 className="card-title text-2xl">Component preview</h2>
</div> <div className="flex flex-wrap gap-3">
</div> <button className="btn btn-primary">Primary action</button>
</div> <button className="btn btn-secondary">Secondary action</button>
</section> <button className="btn btn-accent">Accent action</button>
</div> <button className="btn btn-ghost">Ghost action</button>
</main> </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>
);
} }

View File

@@ -1,47 +1,52 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format // For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from "eslint-plugin-storybook"; import storybook from 'eslint-plugin-storybook';
import js from "@eslint/js"; import js from '@eslint/js';
import prettierConfig from "eslint-config-prettier"; import prettierConfig from 'eslint-config-prettier';
import reactHooks from "eslint-plugin-react-hooks"; import reactHooks from 'eslint-plugin-react-hooks';
import globals from "globals"; import globals from 'globals';
import tseslint from "typescript-eslint"; import tseslint from 'typescript-eslint';
export default tseslint.config({ export default tseslint.config(
ignores: ["build/**", "node_modules/**", ".react-router/**", "coverage/**"], {
}, { ignores: ['build/**', 'node_modules/**', '.react-router/**', 'coverage/**'],
files: ["**/*.{ts,tsx}"], },
extends: [ {
js.configs.recommended, files: ['**/*.{ts,tsx}'],
...tseslint.configs.recommended, extends: [
...tseslint.configs.stylistic, js.configs.recommended,
], ...tseslint.configs.recommended,
languageOptions: { ...tseslint.configs.stylistic,
ecmaVersion: "latest", ],
sourceType: "module", languageOptions: {
globals: { ecmaVersion: 'latest',
...globals.browser, sourceType: 'module',
...globals.node, globals: {
}, ...globals.browser,
parserOptions: { ...globals.node,
ecmaFeatures: { },
jsx: true, parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
}, },
}, plugins: {
}, 'react-hooks': reactHooks,
plugins: {
"react-hooks": reactHooks,
},
rules: {
...reactHooks.configs.recommended.rules,
"no-empty-pattern": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
}, },
], rules: {
}, ...reactHooks.configs.recommended.rules,
}, prettierConfig, storybook.configs["flat/recommended"]); 'no-empty-pattern': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
},
prettierConfig,
storybook.configs['flat/recommended'],
);

View File

@@ -1,68 +1,68 @@
{ {
"name": "biergarten-website", "name": "biergarten-website",
"type": "module", "type": "module",
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"dev": "react-router dev", "dev": "react-router dev",
"build": "react-router build", "build": "react-router build",
"start": "NODE_ENV=production node ./build/server/index.js", "start": "NODE_ENV=production node ./build/server/index.js",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"format": "prettier . --write", "format": "prettier . --write",
"format:check": "prettier . --check", "format:check": "prettier . --check",
"typegen": "react-router typegen", "typegen": "react-router typegen",
"typecheck": "npm run typegen && tsc -p tsconfig.json", "typecheck": "npm run typegen && tsc -p tsconfig.json",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build", "build-storybook": "storybook build",
"test:storybook": "vitest run --project storybook", "test:storybook": "vitest run --project storybook",
"test:storybook:playwright": "playwright test -c playwright.storybook.config.ts" "test:storybook:playwright": "playwright test -c playwright.storybook.config.ts"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.9", "@headlessui/react": "^2.2.9",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@react-router/dev": "^7.13.1", "@react-router/dev": "^7.13.1",
"@react-router/express": "^7.13.1", "@react-router/express": "^7.13.1",
"@react-router/node": "^7.13.1", "@react-router/node": "^7.13.1",
"iconoir-react": "^7.11.0", "iconoir-react": "^7.11.0",
"isbot": "^5.1.36", "isbot": "^5.1.36",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-router": "^7.13.1", "react-router": "^7.13.1",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^5.0.1", "@chromatic-com/storybook": "^5.0.1",
"@eslint/js": "^9.0.0", "@eslint/js": "^9.0.0",
"@playwright/test": "^1.58.2", "@playwright/test": "^1.58.2",
"@storybook/addon-a11y": "^10.2.19", "@storybook/addon-a11y": "^10.2.19",
"@storybook/addon-docs": "^10.2.19", "@storybook/addon-docs": "^10.2.19",
"@storybook/addon-onboarding": "^10.2.19", "@storybook/addon-onboarding": "^10.2.19",
"@storybook/addon-vitest": "^10.2.19", "@storybook/addon-vitest": "^10.2.19",
"@storybook/react-vite": "^10.2.19", "@storybook/react-vite": "^10.2.19",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitest/browser-playwright": "^4.1.0", "@vitest/browser-playwright": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0", "@vitest/coverage-v8": "^4.1.0",
"autoprefixer": "^10.4.27", "autoprefixer": "^10.4.27",
"daisyui": "^5.5.19", "daisyui": "^5.5.19",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-storybook": "^10.2.19", "eslint-plugin-storybook": "^10.2.19",
"globals": "^17.4.0", "globals": "^17.4.0",
"playwright": "^1.58.2", "playwright": "^1.58.2",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"storybook": "^10.2.19", "storybook": "^10.2.19",
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.57.0", "typescript-eslint": "^8.57.0",
"vite": "^7.0.0", "vite": "^7.0.0",
"vitest": "^4.1.0" "vitest": "^4.1.0"
} }
} }

View File

@@ -1,20 +1,20 @@
import { defineConfig } from "@playwright/test"; import { defineConfig } from '@playwright/test';
const port = process.env.STORYBOOK_PORT ?? "6006"; const port = process.env.STORYBOOK_PORT ?? '6006';
const baseURL = process.env.STORYBOOK_URL ?? `http://127.0.0.1:${port}`; const baseURL = process.env.STORYBOOK_URL ?? `http://127.0.0.1:${port}`;
export default defineConfig({ export default defineConfig({
testDir: "./tests/playwright", testDir: './tests/playwright',
timeout: 30_000, timeout: 30_000,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
use: { use: {
baseURL, baseURL,
trace: "on-first-retry", trace: 'on-first-retry',
}, },
webServer: { webServer: {
command: `npm run storybook -- --ci --port ${port}`, command: `npm run storybook -- --ci --port ${port}`,
url: baseURL, url: baseURL,
reuseExistingServer: true, reuseExistingServer: true,
timeout: 120_000, timeout: 120_000,
}, },
}); });

View File

@@ -1,5 +1,5 @@
export default { export default {
plugins: { plugins: {
"@tailwindcss/postcss": {}, '@tailwindcss/postcss': {},
}, },
}; };

View File

@@ -1,5 +1,5 @@
import type { Config } from "@react-router/dev/config"; import type { Config } from '@react-router/dev/config';
export default { export default {
ssr: true, ssr: true,
} satisfies Config; } satisfies Config;

View File

@@ -1,4 +1,4 @@
import { Meta } from "@storybook/addon-docs/blocks"; import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Docs/Storybook" /> <Meta title="Docs/Storybook" />

View File

@@ -1,61 +1,68 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from "storybook/test"; import { expect, within } from 'storybook/test';
import FormField from "../app/components/forms/FormField"; import FormField from '../app/components/forms/FormField';
const formFieldDescription = `Reusable labeled input for Biergarten forms. This page shows guided, error, and password states so you can review label spacing, helper text, validation messaging, and ARIA behavior in the same card layout used across the app.`;
const meta = { const meta = {
title: "Forms/FormField", title: 'Forms/FormField',
component: FormField, component: FormField,
tags: ["autodocs"], tags: ['autodocs'],
args: { args: {
id: "email", id: 'email',
name: "email", name: 'email',
type: "email", type: 'email',
label: "Email address", label: 'Email address',
placeholder: "you@example.com", placeholder: 'you@example.com',
hint: "We only use this to manage your account.", hint: 'We only use this to manage your account.',
}, },
parameters: { parameters: {
layout: "centered", layout: 'centered',
}, docs: {
render: (args) => ( description: {
<div className="w-full max-w-md rounded-box bg-base-100 p-6 shadow-lg"> component: formFieldDescription,
<FormField {...args} /> },
</div> },
), },
render: (args) => (
<div className="w-full max-w-md rounded-box bg-base-100 p-6 shadow-lg">
<FormField {...args} />
</div>
),
} satisfies Meta<typeof FormField>; } satisfies Meta<typeof FormField>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const WithHint: Story = { export const WithHint: Story = {
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
await expect(canvas.getByLabelText(/email address/i)).toBeInTheDocument(); await expect(canvas.getByLabelText(/email address/i)).toBeInTheDocument();
await expect(canvas.getByText(/manage your account/i)).toBeInTheDocument(); await expect(canvas.getByText(/manage your account/i)).toBeInTheDocument();
}, },
}; };
export const WithError: Story = { export const WithError: Story = {
args: { args: {
error: "Please enter a valid email address.", error: 'Please enter a valid email address.',
hint: undefined, hint: undefined,
"aria-invalid": true, 'aria-invalid': true,
defaultValue: "not-an-email", defaultValue: 'not-an-email',
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
await expect(canvas.getByText(/valid email address/i)).toBeInTheDocument(); await expect(canvas.getByText(/valid email address/i)).toBeInTheDocument();
await expect(canvas.getByLabelText(/email address/i)).toHaveAttribute("aria-invalid", "true"); await expect(canvas.getByLabelText(/email address/i)).toHaveAttribute('aria-invalid', 'true');
}, },
}; };
export const PasswordField: Story = { export const PasswordField: Story = {
args: { args: {
id: "password", id: 'password',
name: "password", name: 'password',
type: "password", type: 'password',
label: "Password", label: 'Password',
placeholder: "Enter a strong password", placeholder: 'Enter a strong password',
hint: "Use 12 or more characters.", hint: 'Use 12 or more characters.',
}, },
}; };

View File

@@ -1,62 +1,69 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, userEvent, within } from "storybook/test"; import { expect, userEvent, within } from 'storybook/test';
import Navbar from "../app/components/Navbar"; import Navbar from '../app/components/Navbar';
const navbarDescription = `Top-level navigation for the Biergarten website. These stories cover guest, authenticated, and mobile states so you can review branding, route visibility, account menu behavior, and responsive collapse without leaving Storybook.`;
const meta = { const meta = {
title: "Navigation/Navbar", title: 'Navigation/Navbar',
component: Navbar, component: Navbar,
tags: ["autodocs"], tags: ['autodocs'],
parameters: { parameters: {
layout: "fullscreen", layout: 'fullscreen',
}, docs: {
description: {
component: navbarDescription,
},
},
},
} satisfies Meta<typeof Navbar>; } satisfies Meta<typeof Navbar>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Guest: Story = { export const Guest: Story = {
args: { args: {
auth: null, auth: null,
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
await expect(canvas.getByRole("link", { name: /the biergarten app/i })).toBeInTheDocument(); await expect(canvas.getByRole('link', { name: /the biergarten app/i })).toBeInTheDocument();
await expect(canvas.getByRole("link", { name: /login/i })).toBeInTheDocument(); await expect(canvas.getByRole('link', { name: /login/i })).toBeInTheDocument();
await expect(canvas.getByRole("link", { name: /register user/i })).toBeInTheDocument(); await expect(canvas.getByRole('link', { name: /register user/i })).toBeInTheDocument();
}, },
}; };
export const Authenticated: Story = { export const Authenticated: Story = {
args: { args: {
auth: { auth: {
username: "Hans", username: 'Hans',
accessToken: "access-token", accessToken: 'access-token',
refreshToken: "refresh-token", refreshToken: 'refresh-token',
userAccountId: "user-1", userAccountId: 'user-1',
}, },
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const userButton = canvas.getByRole("button", { name: /hans/i }); const userButton = canvas.getByRole('button', { name: /hans/i });
await expect(userButton).toBeInTheDocument(); await expect(userButton).toBeInTheDocument();
await userEvent.click(userButton); await userEvent.click(userButton);
await expect(canvas.getByRole("menuitem", { name: /dashboard/i })).toBeInTheDocument(); await expect(canvas.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
await expect(canvas.getByRole("menuitem", { name: /logout/i })).toBeInTheDocument(); await expect(canvas.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
}, },
}; };
export const MobileMenu: Story = { export const MobileMenu: Story = {
args: { args: {
auth: null, auth: null,
}, },
parameters: { parameters: {
viewport: { viewport: {
defaultViewport: "mobile1", defaultViewport: 'mobile1',
}, },
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: /toggle navigation/i })); await userEvent.click(canvas.getByRole('button', { name: /toggle navigation/i }));
await expect(canvas.getByRole("link", { name: /beer styles/i })).toBeInTheDocument(); await expect(canvas.getByRole('link', { name: /beer styles/i })).toBeInTheDocument();
}, },
}; };

View File

@@ -1,45 +1,52 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from "storybook/test"; import { expect, within } from 'storybook/test';
import SubmitButton from "../app/components/forms/SubmitButton"; import SubmitButton from '../app/components/forms/SubmitButton';
const submitButtonDescription = `Shared submit action for Biergarten forms. These stories cover the idle, loading, and custom-width states so you can verify button copy, disabled behavior during submission, and theme styling without wiring up a full form flow.`;
const meta = { const meta = {
title: "Forms/SubmitButton", title: 'Forms/SubmitButton',
component: SubmitButton, component: SubmitButton,
tags: ["autodocs"], tags: ['autodocs'],
args: { args: {
idleText: "Save changes", idleText: 'Save changes',
submittingText: "Saving changes", submittingText: 'Saving changes',
isSubmitting: false, isSubmitting: false,
}, },
parameters: { parameters: {
layout: "centered", layout: 'centered',
}, docs: {
description: {
component: submitButtonDescription,
},
},
},
} satisfies Meta<typeof SubmitButton>; } satisfies Meta<typeof SubmitButton>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Idle: Story = { export const Idle: Story = {
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
await expect(canvas.getByRole("button", { name: /save changes/i })).toBeEnabled(); await expect(canvas.getByRole('button', { name: /save changes/i })).toBeEnabled();
}, },
}; };
export const Submitting: Story = { export const Submitting: Story = {
args: { args: {
isSubmitting: true, isSubmitting: true,
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
await expect(canvas.getByRole("button", { name: /saving changes/i })).toBeDisabled(); await expect(canvas.getByRole('button', { name: /saving changes/i })).toBeDisabled();
}, },
}; };
export const CustomWidth: Story = { export const CustomWidth: Story = {
args: { args: {
className: "btn btn-secondary min-w-64", className: 'btn btn-secondary min-w-64',
idleText: "Register user", idleText: 'Register user',
submittingText: "Registering user", submittingText: 'Registering user',
}, },
}; };

View File

@@ -1,67 +1,157 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from "storybook/test"; import { expect, within } from 'storybook/test';
import { biergartenThemes } from "../app/lib/themes"; import { biergartenThemes } from '../app/lib/themes';
const themesDescription = `Palette reference for all Biergarten themes. Each panel shows the main semantic color pairs, status tokens, and custom content tokens so you can catch contrast issues, pairing mistakes, and mood drift before they show up in real components.`;
function ThemeSwatch({ label, className }: { label: string; className: string }) { function ThemeSwatch({ label, className }: { label: string; className: string }) {
return <div className={`rounded-box p-3 text-sm font-medium ${className}`}>{label}</div>; return <div className={`rounded-box p-3 text-sm font-medium ${className}`}>{label}</div>;
}
/** For custom tokens not covered by Tailwind utilities (surface, muted, highlight). */
function CssVarSwatch({ label, bg, color }: { label: string; bg: string; color: string }) {
return (
<div
className="rounded-box p-3 text-sm font-medium"
style={{ backgroundColor: `var(${bg})`, color: `var(${color})` }}
>
{label}
</div>
);
}
function TextTokenSample({
label,
background,
text,
}: {
label: string;
background: string;
text: string;
}) {
return (
<div className="rounded-box p-3" style={{ backgroundColor: `var(${background})` }}>
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
{label}
</p>
<p className="mt-1 text-sm font-medium" style={{ color: `var(${text})` }}>
Secondary copy, placeholders, and helper text.
</p>
</div>
);
} }
function ThemePanel({ label, value, vibe }: { label: string; value: string; vibe: string }) { function ThemePanel({ label, value, vibe }: { label: string; value: string; vibe: string }) {
return ( return (
<section <section
data-theme={value} data-theme={value}
className="rounded-box border border-base-300 bg-base-100 shadow-lg" className="rounded-box border border-base-300 bg-base-100 shadow-lg"
> >
<div className="space-y-4 p-5"> <div className="space-y-4 p-5">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-2xl font-bold">{label}</h2> <h2 className="text-2xl font-bold">{label}</h2>
<p className="text-sm text-base-content/70">{vibe}</p> <p className="text-sm text-base-content/70">{vibe}</p>
</div> </div>
<div className="grid gap-2 sm:grid-cols-2"> {/* Core palette */}
<ThemeSwatch label="Primary" className="bg-primary text-primary-content" /> <div>
<ThemeSwatch label="Secondary" className="bg-secondary text-secondary-content" /> <p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
<ThemeSwatch label="Accent" className="bg-accent text-accent-content" /> Core
<ThemeSwatch label="Neutral" className="bg-neutral text-neutral-content" /> </p>
</div> <div className="grid gap-2 sm:grid-cols-2">
<ThemeSwatch label="Primary" className="bg-primary text-primary-content" />
<ThemeSwatch label="Secondary" className="bg-secondary text-secondary-content" />
<ThemeSwatch label="Accent" className="bg-accent text-accent-content" />
<ThemeSwatch label="Neutral" className="bg-neutral text-neutral-content" />
</div>
</div>
<div className="flex flex-wrap gap-2"> {/* Status tokens */}
<button className="btn btn-primary btn-sm">Primary</button> <div>
<button className="btn btn-secondary btn-sm">Secondary</button> <p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
<button className="btn btn-outline btn-sm">Outline</button> Status
</div> </p>
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4">
<ThemeSwatch label="Info" className="bg-info text-info-content" />
<ThemeSwatch label="Success" className="bg-success text-success-content" />
<ThemeSwatch label="Warning" className="bg-warning text-warning-content" />
<ThemeSwatch label="Error" className="bg-error text-error-content" />
</div>
</div>
<div role="alert" className="alert alert-info alert-soft"> {/* Content tokens (custom) */}
<span>Semantic tokens stay stable while the atmosphere changes.</span> <div>
</div> <p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
</div> Content
</section> </p>
); <div className="grid gap-2 sm:grid-cols-3">
<CssVarSwatch
label="Surface"
bg="--color-surface"
color="--color-surface-content"
/>
<TextTokenSample
label="Muted on Base"
background="--color-base-100"
text="--color-muted"
/>
<CssVarSwatch
label="Highlight"
bg="--color-highlight"
color="--color-highlight-content"
/>
</div>
<div className="mt-2">
<TextTokenSample
label="Muted on Surface"
background="--color-surface"
text="--color-muted"
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button className="btn btn-primary btn-sm">Primary</button>
<button className="btn btn-secondary btn-sm">Secondary</button>
<button className="btn btn-outline btn-sm">Outline</button>
</div>
<div role="alert" className="alert alert-success alert-soft">
<span>Semantic tokens stay stable while the atmosphere changes.</span>
</div>
</div>
</section>
);
} }
const meta = { const meta = {
title: "Themes/Biergarten Themes", title: 'Themes/Biergarten Themes',
parameters: { parameters: {
layout: "fullscreen", layout: 'fullscreen',
}, docs: {
tags: ["autodocs"], description: {
render: () => ( component: themesDescription,
<div className="grid gap-6 p-6 lg:grid-cols-2"> },
{biergartenThemes.map((theme) => ( },
<ThemePanel key={theme.value} {...theme} /> },
))} tags: ['autodocs'],
</div> render: () => (
), <div className="grid gap-6 p-6 lg:grid-cols-2">
{biergartenThemes.map((theme) => (
<ThemePanel key={theme.value} {...theme} />
))}
</div>
),
} satisfies Meta; } satisfies Meta;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Gallery: Story = { export const Gallery: Story = {
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
for (const theme of biergartenThemes) { for (const theme of biergartenThemes) {
await expect(canvas.getByRole("heading", { name: theme.label })).toBeInTheDocument(); await expect(canvas.getByRole('heading', { name: theme.label })).toBeInTheDocument();
} }
}, },
}; };

View File

@@ -1,63 +1,74 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, within } from "storybook/test"; import { expect, screen, userEvent, within } from 'storybook/test';
import ToastProvider from "../app/components/toast/ToastProvider"; import ToastProvider from '../app/components/toast/ToastProvider';
import { import {
dismissToasts, dismissToasts,
showErrorToast, showErrorToast,
showInfoToast, showInfoToast,
showSuccessToast, showSuccessToast,
} from "../app/components/toast/toast"; } from '../app/components/toast/toast';
const toastDescription = `Theme-aware toast feedback built on react-hot-toast. Use this page to trigger success, error, and info messages, check icon contrast and surface styling, and confirm notifications feel consistent across Biergarten themes.`;
function ToastDemo() { function ToastDemo() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<ToastProvider /> <ToastProvider />
<div className="card border border-base-300 bg-base-100 shadow-md"> <div className="card border border-base-300 bg-base-100 shadow-md">
<div className="card-body"> <div className="card-body">
<h2 className="card-title">Toast demo</h2> <h2 className="card-title">Toast demo</h2>
<p className="text-sm text-base-content/70">Use these actions to preview toast styles.</p> <p className="text-sm text-base-content/70">
<div className="flex flex-wrap gap-2"> Use these actions to preview toast styles.
<button </p>
className="btn btn-success btn-sm" <div className="flex flex-wrap gap-2">
onClick={() => showSuccessToast("Saved successfully")} <button
> className="btn btn-success btn-sm"
Success onClick={() => showSuccessToast('Saved successfully')}
</button> >
<button Success
className="btn btn-error btn-sm" </button>
onClick={() => showErrorToast("Something went wrong")} <button
> className="btn btn-error btn-sm"
Error onClick={() => showErrorToast('Something went wrong')}
</button> >
<button Error
className="btn btn-info btn-sm" </button>
onClick={() => showInfoToast("Heads up: check your email")} <button
> className="btn btn-info btn-sm"
Info onClick={() => showInfoToast('Heads up: check your email')}
</button> >
<button className="btn btn-ghost btn-sm" onClick={dismissToasts}> Info
Dismiss all </button>
</button> <button className="btn btn-ghost btn-sm" onClick={dismissToasts}>
</div> Dismiss all
</div> </button>
</div>
</div>
</div>
</div> </div>
</div> );
);
} }
const meta = { const meta = {
title: "Feedback/Toast", title: 'Feedback/Toast',
component: ToastDemo, component: ToastDemo,
tags: ["autodocs"], tags: ['autodocs'],
parameters: {
docs: {
description: {
component: toastDescription,
},
},
},
} satisfies Meta<typeof ToastDemo>; } satisfies Meta<typeof ToastDemo>;
export default meta; export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Playground: Story = { export const Playground: Story = {
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: /success/i })); await userEvent.click(canvas.getByRole('button', { name: /success/i }));
await expect(screen.getByText(/saved successfully/i)).toBeInTheDocument(); await expect(screen.getByText(/saved successfully/i)).toBeInTheDocument();
}, },
}; };

View File

@@ -1,8 +1,8 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ["./app/**/*.{ts,tsx}"], content: ['./app/**/*.{ts,tsx}'],
theme: { theme: {
extend: {}, extend: {},
}, },
plugins: [require("daisyui")], plugins: [require('daisyui')],
}; };

View File

@@ -1,49 +1,49 @@
import { expect, test } from "@playwright/test"; import { expect, test } from '@playwright/test';
const themes = [ const themes = [
"biergarten-lager", 'biergarten-lager',
"biergarten-stout", 'biergarten-stout',
"biergarten-cassis", 'biergarten-cassis',
"biergarten-weizen", 'biergarten-weizen',
] as const; ] as const;
test.describe("storybook component coverage", () => { test.describe('storybook component coverage', () => {
for (const theme of themes) { for (const theme of themes) {
test(`SubmitButton idle renders in ${theme}`, async ({ page }) => { test(`SubmitButton idle renders in ${theme}`, async ({ page }) => {
await page.goto(`/iframe.html?id=forms-submitbutton--idle&globals=theme:${theme}`); await page.goto(`/iframe.html?id=forms-submitbutton--idle&globals=theme:${theme}`);
await expect(page.getByRole("button", { name: /save changes/i })).toBeVisible(); await expect(page.getByRole('button', { name: /save changes/i })).toBeVisible();
await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible(); await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible();
}); });
test(`FormField error renders in ${theme}`, async ({ page }) => { test(`FormField error renders in ${theme}`, async ({ page }) => {
await page.goto(`/iframe.html?id=forms-formfield--with-error&globals=theme:${theme}`); await page.goto(`/iframe.html?id=forms-formfield--with-error&globals=theme:${theme}`);
await expect(page.getByLabel("Email address")).toBeVisible(); await expect(page.getByLabel('Email address')).toBeVisible();
await expect(page.getByText(/valid email address/i)).toBeVisible(); await expect(page.getByText(/valid email address/i)).toBeVisible();
await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible(); await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible();
}); });
test(`Navbar guest renders in ${theme}`, async ({ page }) => { test(`Navbar guest renders in ${theme}`, async ({ page }) => {
await page.goto(`/iframe.html?id=navigation-navbar--guest&globals=theme:${theme}`); await page.goto(`/iframe.html?id=navigation-navbar--guest&globals=theme:${theme}`);
await expect(page.getByRole("link", { name: /the biergarten app/i })).toBeVisible(); await expect(page.getByRole('link', { name: /the biergarten app/i })).toBeVisible();
await expect(page.getByRole("link", { name: /^login$/i })).toBeVisible(); await expect(page.getByRole('link', { name: /^login$/i })).toBeVisible();
await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible(); await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible();
}); });
} }
test("Navbar authenticated state renders", async ({ page }) => { test('Navbar authenticated state renders', async ({ page }) => {
await page.goto( await page.goto(
`/iframe.html?id=navigation-navbar--authenticated&globals=theme:biergarten-stout`, `/iframe.html?id=navigation-navbar--authenticated&globals=theme:biergarten-stout`,
); );
await expect(page.getByRole("button", { name: /hans/i })).toBeVisible(); await expect(page.getByRole('button', { name: /hans/i })).toBeVisible();
}); });
test("Theme gallery shows all themes", async ({ page }) => { test('Theme gallery shows all themes', async ({ page }) => {
await page.goto( await page.goto(
`/iframe.html?id=themes-biergarten-themes--gallery&globals=theme:biergarten-lager`, `/iframe.html?id=themes-biergarten-themes--gallery&globals=theme:biergarten-lager`,
); );
await expect(page.getByRole("heading", { name: "Biergarten Lager" })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Biergarten Lager' })).toBeVisible();
await expect(page.getByRole("heading", { name: "Biergarten Stout" })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Biergarten Stout' })).toBeVisible();
await expect(page.getByRole("heading", { name: "Biergarten Cassis" })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Biergarten Cassis' })).toBeVisible();
await expect(page.getByRole("heading", { name: "Biergarten Weizen" })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Biergarten Weizen' })).toBeVisible();
}); });
}); });

View File

@@ -1,19 +1,19 @@
{ {
"compilerOptions": { "compilerOptions": {
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2022"], "lib": ["DOM", "DOM.Iterable", "ES2022"],
"moduleResolution": "bundler", "moduleResolution": "bundler",
"module": "ESNext", "module": "ESNext",
"noEmit": true, "noEmit": true,
"rootDirs": [".", "./.react-router/types"], "rootDirs": [".", "./.react-router/types"],
"resolveJsonModule": true, "resolveJsonModule": true,
"types": ["node", "vite/client"], "types": ["node", "vite/client"],
"target": "ES2022", "target": "ES2022",
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["app", ".react-router/types/**/*", "react-router.config.ts"], "include": ["app", ".react-router/types/**/*", "react-router.config.ts"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }

View File

@@ -1,47 +1,47 @@
/// <reference types="vitest/config" /> /// <reference types="vitest/config" />
import { reactRouter } from "@react-router/dev/vite"; import { reactRouter } from '@react-router/dev/vite';
import { storybookTest } from "@storybook/addon-vitest/vitest-plugin"; import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
import { playwright } from "@vitest/browser-playwright"; import { playwright } from '@vitest/browser-playwright';
import path from "node:path"; import path from 'node:path';
import { fileURLToPath } from "node:url"; import { fileURLToPath } from 'node:url';
import { defineConfig } from "vite"; import { defineConfig } from 'vite';
const dirname = const dirname =
typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url)); typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
const isStorybook = const isStorybook =
process.env.STORYBOOK === "true" || process.argv.some((arg) => arg.includes("storybook")); process.env.STORYBOOK === 'true' || process.argv.some((arg) => arg.includes('storybook'));
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default defineConfig({ export default defineConfig({
plugins: isStorybook ? [] : [reactRouter()], plugins: isStorybook ? [] : [reactRouter()],
resolve: { resolve: {
dedupe: ["react", "react-dom"], dedupe: ['react', 'react-dom'],
}, },
test: { test: {
projects: [ projects: [
{ {
extends: true, extends: true,
plugins: [ plugins: [
// The plugin will run tests for the stories defined in your Storybook config // The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({ storybookTest({
configDir: path.join(dirname, ".storybook"), configDir: path.join(dirname, '.storybook'),
}), }),
],
test: {
name: "storybook",
browser: {
enabled: true,
headless: true,
provider: playwright({}),
instances: [
{
browser: "chromium",
},
], ],
}, test: {
setupFiles: [".storybook/vitest.setup.ts"], name: 'storybook',
}, browser: {
}, enabled: true,
], headless: true,
}, provider: playwright({}),
instances: [
{
browser: 'chromium',
},
],
},
setupFiles: ['.storybook/vitest.setup.ts'],
},
},
],
},
}); });