From bfd45842fe1b5e6267b7326c980dbe2e24f8db2e Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Sun, 23 Aug 2026 01:30:58 -0400 Subject: [PATCH] Refactor authentication and account management with modular UI components (#242) --- .gitignore | 10 +- docs/website/testing.md | 20 + web/frontend/.gitignore | 3 + web/frontend/.prettierrc.json | 18 +- web/frontend/.react-router/types/+future.ts | 9 - web/frontend/.react-router/types/+routes.ts | 105 - .../.react-router/types/+server-build.d.ts | 18 - .../.react-router/types/app/+types/root.ts | 59 - .../types/app/routes/+types/beer-styles.ts | 62 - .../types/app/routes/+types/beers.ts | 62 - .../types/app/routes/+types/breweries.ts | 62 - .../types/app/routes/+types/confirm.ts | 62 - .../types/app/routes/+types/dashboard.ts | 62 - .../types/app/routes/+types/home.ts | 62 - .../types/app/routes/+types/login.ts | 62 - .../types/app/routes/+types/logout.ts | 62 - .../types/app/routes/+types/register.ts | 62 - .../types/app/routes/+types/theme.ts | 62 - web/frontend/.storybook/main.ts | 83 +- web/frontend/.storybook/preview-head.html | 4 +- web/frontend/.storybook/preview.ts | 113 +- web/frontend/app/app.css | 312 +- web/frontend/app/components/Navbar.tsx | 144 - .../app/components/forms/FormField.tsx | 40 - .../app/components/forms/SubmitButton.tsx | 31 - .../app/components/toast/ToastProvider.tsx | 25 - .../app/components/ui/forms/FormField.tsx | 42 + .../app/components/ui/forms/SubmitButton.tsx | 28 + .../app/components/ui/navbar/NavLinks.tsx | 44 + .../app/components/ui/navbar/Navbar.tsx | 93 + .../app/components/ui/navbar/UserMenu.tsx | 30 + .../app/components/ui/toast/ToastProvider.tsx | 28 + .../app/components/{ => ui}/toast/toast.ts | 0 .../account/components/AuthFlowDemoCard.tsx | 76 + .../components/DeleteAccountSection.tsx | 98 + .../account/components/EmailSection.tsx | 57 + .../account/components/PasswordSection.tsx | 75 + .../account/components/ProfileSection.tsx | 82 + .../account/components/SectionCard.tsx | 36 + .../account/components/SessionInfoCard.tsx | 42 + .../account/components/UsernameSection.tsx | 61 + .../features/account/hooks/useSectionError.ts | 10 + .../app/features/account/routes/account.tsx | 269 + .../app/features/account/routes/dashboard.tsx | 31 + web/frontend/app/features/account/schemas.ts | 48 + web/frontend/app/features/account/state.ts | 25 + web/frontend/app/features/account/types.ts | 31 + web/frontend/app/features/auth/auth.server.ts | 267 + .../auth/components/ConfirmFailure.tsx | 25 + .../auth/components/ConfirmSuccess.tsx | 26 + .../features/auth/components/LoginCallout.tsx | 18 + .../features/auth/components/LoginForm.tsx | 44 + .../auth/components/RegisterCallout.tsx | 23 + .../features/auth/components/RegisterForm.tsx | 97 + .../auth/hooks/useActionErrorToast.ts | 8 + .../app/features/auth/routes/confirm.tsx | 49 + .../app/features/auth/routes/login.tsx | 88 + .../app/features/auth/routes/logout.tsx | 10 + .../app/features/auth/routes/register.tsx | 106 + web/frontend/app/features/auth/schemas.ts | 33 + .../features/breweries/breweries.server.ts | 55 + .../breweries/components/BreweryCard.tsx | 26 + .../features/breweries/routes/breweries.tsx | 39 + .../breweries/routes/brewery-detail.tsx | 59 + .../features/catalog/routes/beer-styles.tsx | 18 + .../app/features/catalog/routes/beers.tsx | 18 + .../app/features/home/routes/home.tsx | 56 + .../theme/components/ThemeSwitcher.tsx | 87 + .../app/features/theme/routes/theme.tsx | 99 + web/frontend/app/features/theme/themes.ts | 38 + web/frontend/app/lib/auth.server.ts | 162 - web/frontend/app/lib/breweries.server.ts | 55 - web/frontend/app/lib/schemas.ts | 33 - web/frontend/app/lib/themes.ts | 41 - web/frontend/app/root.tsx | 161 +- web/frontend/app/routes.ts | 22 +- web/frontend/app/routes/beer-styles.tsx | 16 - web/frontend/app/routes/beers.tsx | 16 - web/frontend/app/routes/breweries.tsx | 56 - web/frontend/app/routes/brewery-detail.tsx | 57 - web/frontend/app/routes/confirm.tsx | 91 - web/frontend/app/routes/dashboard.tsx | 105 - web/frontend/app/routes/home.tsx | 56 - web/frontend/app/routes/login.tsx | 128 - web/frontend/app/routes/logout.tsx | 10 - web/frontend/app/routes/register.tsx | 189 - web/frontend/app/routes/theme.tsx | 169 - web/frontend/eslint.config.mjs | 93 +- web/frontend/package-lock.json | 18439 +++++++++------- web/frontend/package.json | 137 +- web/frontend/playwright.storybook.config.ts | 26 +- web/frontend/postcss.config.js | 6 +- web/frontend/react-router.config.ts | 2 +- web/frontend/stories/Configure.mdx | 8 + web/frontend/stories/FormField.stories.tsx | 101 +- web/frontend/stories/LoginForm.stories.tsx | 67 + web/frontend/stories/Navbar.stories.tsx | 100 +- .../stories/PasswordSection.stories.tsx | 132 + web/frontend/stories/RegisterForm.stories.tsx | 116 + web/frontend/stories/SectionCard.stories.tsx | 75 + web/frontend/stories/SubmitButton.stories.tsx | 66 +- web/frontend/stories/Themes.stories.tsx | 269 +- web/frontend/stories/Toast.stories.tsx | 116 +- .../stories/UsernameSection.stories.tsx | 107 + web/frontend/tailwind.config.js | 10 +- .../playwright/storybook.components.spec.ts | 83 +- web/frontend/tsconfig.json | 34 +- web/frontend/vite.config.ts | 66 +- web/package-lock.json | 6 + 109 files changed, 14743 insertions(+), 10792 deletions(-) create mode 100644 web/frontend/.gitignore delete mode 100644 web/frontend/.react-router/types/+future.ts delete mode 100644 web/frontend/.react-router/types/+routes.ts delete mode 100644 web/frontend/.react-router/types/+server-build.d.ts delete mode 100644 web/frontend/.react-router/types/app/+types/root.ts delete mode 100644 web/frontend/.react-router/types/app/routes/+types/beer-styles.ts delete mode 100644 web/frontend/.react-router/types/app/routes/+types/beers.ts delete mode 100644 web/frontend/.react-router/types/app/routes/+types/breweries.ts delete mode 100644 web/frontend/.react-router/types/app/routes/+types/confirm.ts delete mode 100644 web/frontend/.react-router/types/app/routes/+types/dashboard.ts delete mode 100644 web/frontend/.react-router/types/app/routes/+types/home.ts delete mode 100644 web/frontend/.react-router/types/app/routes/+types/login.ts delete mode 100644 web/frontend/.react-router/types/app/routes/+types/logout.ts delete mode 100644 web/frontend/.react-router/types/app/routes/+types/register.ts delete mode 100644 web/frontend/.react-router/types/app/routes/+types/theme.ts delete mode 100644 web/frontend/app/components/Navbar.tsx delete mode 100644 web/frontend/app/components/forms/FormField.tsx delete mode 100644 web/frontend/app/components/forms/SubmitButton.tsx delete mode 100644 web/frontend/app/components/toast/ToastProvider.tsx create mode 100644 web/frontend/app/components/ui/forms/FormField.tsx create mode 100644 web/frontend/app/components/ui/forms/SubmitButton.tsx create mode 100644 web/frontend/app/components/ui/navbar/NavLinks.tsx create mode 100644 web/frontend/app/components/ui/navbar/Navbar.tsx create mode 100644 web/frontend/app/components/ui/navbar/UserMenu.tsx create mode 100644 web/frontend/app/components/ui/toast/ToastProvider.tsx rename web/frontend/app/components/{ => ui}/toast/toast.ts (100%) create mode 100644 web/frontend/app/features/account/components/AuthFlowDemoCard.tsx create mode 100644 web/frontend/app/features/account/components/DeleteAccountSection.tsx create mode 100644 web/frontend/app/features/account/components/EmailSection.tsx create mode 100644 web/frontend/app/features/account/components/PasswordSection.tsx create mode 100644 web/frontend/app/features/account/components/ProfileSection.tsx create mode 100644 web/frontend/app/features/account/components/SectionCard.tsx create mode 100644 web/frontend/app/features/account/components/SessionInfoCard.tsx create mode 100644 web/frontend/app/features/account/components/UsernameSection.tsx create mode 100644 web/frontend/app/features/account/hooks/useSectionError.ts create mode 100644 web/frontend/app/features/account/routes/account.tsx create mode 100644 web/frontend/app/features/account/routes/dashboard.tsx create mode 100644 web/frontend/app/features/account/schemas.ts create mode 100644 web/frontend/app/features/account/state.ts create mode 100644 web/frontend/app/features/account/types.ts create mode 100644 web/frontend/app/features/auth/auth.server.ts create mode 100644 web/frontend/app/features/auth/components/ConfirmFailure.tsx create mode 100644 web/frontend/app/features/auth/components/ConfirmSuccess.tsx create mode 100644 web/frontend/app/features/auth/components/LoginCallout.tsx create mode 100644 web/frontend/app/features/auth/components/LoginForm.tsx create mode 100644 web/frontend/app/features/auth/components/RegisterCallout.tsx create mode 100644 web/frontend/app/features/auth/components/RegisterForm.tsx create mode 100644 web/frontend/app/features/auth/hooks/useActionErrorToast.ts create mode 100644 web/frontend/app/features/auth/routes/confirm.tsx create mode 100644 web/frontend/app/features/auth/routes/login.tsx create mode 100644 web/frontend/app/features/auth/routes/logout.tsx create mode 100644 web/frontend/app/features/auth/routes/register.tsx create mode 100644 web/frontend/app/features/auth/schemas.ts create mode 100644 web/frontend/app/features/breweries/breweries.server.ts create mode 100644 web/frontend/app/features/breweries/components/BreweryCard.tsx create mode 100644 web/frontend/app/features/breweries/routes/breweries.tsx create mode 100644 web/frontend/app/features/breweries/routes/brewery-detail.tsx create mode 100644 web/frontend/app/features/catalog/routes/beer-styles.tsx create mode 100644 web/frontend/app/features/catalog/routes/beers.tsx create mode 100644 web/frontend/app/features/home/routes/home.tsx create mode 100644 web/frontend/app/features/theme/components/ThemeSwitcher.tsx create mode 100644 web/frontend/app/features/theme/routes/theme.tsx create mode 100644 web/frontend/app/features/theme/themes.ts delete mode 100644 web/frontend/app/lib/auth.server.ts delete mode 100644 web/frontend/app/lib/breweries.server.ts delete mode 100644 web/frontend/app/lib/schemas.ts delete mode 100644 web/frontend/app/lib/themes.ts delete mode 100644 web/frontend/app/routes/beer-styles.tsx delete mode 100644 web/frontend/app/routes/beers.tsx delete mode 100644 web/frontend/app/routes/breweries.tsx delete mode 100644 web/frontend/app/routes/brewery-detail.tsx delete mode 100644 web/frontend/app/routes/confirm.tsx delete mode 100644 web/frontend/app/routes/dashboard.tsx delete mode 100644 web/frontend/app/routes/home.tsx delete mode 100644 web/frontend/app/routes/login.tsx delete mode 100644 web/frontend/app/routes/logout.tsx delete mode 100644 web/frontend/app/routes/register.tsx delete mode 100644 web/frontend/app/routes/theme.tsx create mode 100644 web/frontend/stories/LoginForm.stories.tsx create mode 100644 web/frontend/stories/PasswordSection.stories.tsx create mode 100644 web/frontend/stories/RegisterForm.stories.tsx create mode 100644 web/frontend/stories/SectionCard.stories.tsx create mode 100644 web/frontend/stories/UsernameSection.stories.tsx create mode 100644 web/package-lock.json diff --git a/.gitignore b/.gitignore index 2868f19a..eef46f41 100644 --- a/.gitignore +++ b/.gitignore @@ -16,11 +16,11 @@ /build # project-specific build artifacts -/src/Website/build/ -/src/Website/storybook-static/ -/src/Website/.react-router/ -/src/Website/playwright-report/ -/src/Website/test-results/ +/web/frontend/build/ +/web/frontend/storybook-static/ +/web/frontend/.react-router/ +/web/frontend/playwright-report/ +/web/frontend/test-results/ /test-results/ # misc diff --git a/docs/website/testing.md b/docs/website/testing.md index 9eee38a6..a7fc13bb 100644 --- a/docs/website/testing.md +++ b/docs/website/testing.md @@ -1,3 +1,4 @@ + # Testing This document describes the testing strategy and how to run tests for The @@ -125,6 +126,25 @@ npm run test:storybook:playwright - The command will start or reuse the Storybook server defined in `playwright.storybook.config.ts` +### Running Storybook tests with a visible browser + +Both of the above run headless by default. To watch the browser while +debugging a failure: + +```bash +# Playwright Storybook suite +npx playwright test -c playwright.storybook.config.ts --headed +# or the interactive step-through runner +npx playwright test -c playwright.storybook.config.ts --ui + +# Vitest/story play-function suite +npx vitest run --project storybook --browser.headless=false +# or Vitest's own UI (drop `run` for watch mode) +npx vitest --project storybook --browser.headless=false --ui +``` + +The default (`headless: true`) is set in `web/frontend/vite.config.ts`. + ## Test coverage ### Current coverage diff --git a/web/frontend/.gitignore b/web/frontend/.gitignore new file mode 100644 index 00000000..3a9deb15 --- /dev/null +++ b/web/frontend/.gitignore @@ -0,0 +1,3 @@ +.features-gen/ +.react-router + diff --git a/web/frontend/.prettierrc.json b/web/frontend/.prettierrc.json index aa38807f..eb1a3b84 100644 --- a/web/frontend/.prettierrc.json +++ b/web/frontend/.prettierrc.json @@ -1,11 +1,11 @@ { - "$schema": "https://json.schemastore.org/prettierrc", - "printWidth": 100, - "singleQuote": true, - "trailingComma": "all", - "semi": true, - "tabWidth": 3, - "useTabs": false, - "arrowParens": "always", - "endOfLine": "lf" + "$schema": "https://json.schemastore.org/prettierrc", + "printWidth": 100, + "singleQuote": true, + "trailingComma": "all", + "semi": true, + "tabWidth": 4, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf" } diff --git a/web/frontend/.react-router/types/+future.ts b/web/frontend/.react-router/types/+future.ts deleted file mode 100644 index 7f4533cc..00000000 --- a/web/frontend/.react-router/types/+future.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Generated by React Router - -import "react-router"; - -declare module "react-router" { - interface Future { - v8_middleware: false - } -} \ No newline at end of file diff --git a/web/frontend/.react-router/types/+routes.ts b/web/frontend/.react-router/types/+routes.ts deleted file mode 100644 index b4f2cc9e..00000000 --- a/web/frontend/.react-router/types/+routes.ts +++ /dev/null @@ -1,105 +0,0 @@ -// Generated by React Router - -import "react-router" - -declare module "react-router" { - interface Register { - pages: Pages - routeFiles: RouteFiles - routeModules: RouteModules - } -} - -type Pages = { - "/": { - params: {}; - }; - "/theme": { - params: {}; - }; - "/login": { - params: {}; - }; - "/register": { - params: {}; - }; - "/logout": { - params: {}; - }; - "/dashboard": { - params: {}; - }; - "/confirm": { - params: {}; - }; - "/beers": { - params: {}; - }; - "/breweries": { - params: {}; - }; - "/beer-styles": { - params: {}; - }; -}; - -type RouteFiles = { - "root.tsx": { - id: "root"; - page: "/" | "/theme" | "/login" | "/register" | "/logout" | "/dashboard" | "/confirm" | "/beers" | "/breweries" | "/beer-styles"; - }; - "routes/home.tsx": { - id: "routes/home"; - page: "/"; - }; - "routes/theme.tsx": { - id: "routes/theme"; - page: "/theme"; - }; - "routes/login.tsx": { - id: "routes/login"; - page: "/login"; - }; - "routes/register.tsx": { - id: "routes/register"; - page: "/register"; - }; - "routes/logout.tsx": { - id: "routes/logout"; - page: "/logout"; - }; - "routes/dashboard.tsx": { - id: "routes/dashboard"; - page: "/dashboard"; - }; - "routes/confirm.tsx": { - id: "routes/confirm"; - page: "/confirm"; - }; - "routes/beers.tsx": { - id: "routes/beers"; - page: "/beers"; - }; - "routes/breweries.tsx": { - id: "routes/breweries"; - page: "/breweries"; - }; - "routes/beer-styles.tsx": { - id: "routes/beer-styles"; - page: "/beer-styles"; - }; -}; - -type RouteModules = { - "root": typeof import("./app/root.tsx"); - "routes/home": typeof import("./app/routes/home.tsx"); - "routes/theme": typeof import("./app/routes/theme.tsx"); - "routes/login": typeof import("./app/routes/login.tsx"); - "routes/register": typeof import("./app/routes/register.tsx"); - "routes/logout": typeof import("./app/routes/logout.tsx"); - "routes/dashboard": typeof import("./app/routes/dashboard.tsx"); - "routes/confirm": typeof import("./app/routes/confirm.tsx"); - "routes/beers": typeof import("./app/routes/beers.tsx"); - "routes/breweries": typeof import("./app/routes/breweries.tsx"); - "routes/beer-styles": typeof import("./app/routes/beer-styles.tsx"); -}; \ No newline at end of file diff --git a/web/frontend/.react-router/types/+server-build.d.ts b/web/frontend/.react-router/types/+server-build.d.ts deleted file mode 100644 index 13792c13..00000000 --- a/web/frontend/.react-router/types/+server-build.d.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Generated by React Router - -declare module "virtual:react-router/server-build" { - import { ServerBuild } from "react-router"; - export const assets: ServerBuild["assets"]; - export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"]; - export const basename: ServerBuild["basename"]; - export const entry: ServerBuild["entry"]; - export const future: ServerBuild["future"]; - export const isSpaMode: ServerBuild["isSpaMode"]; - export const prerender: ServerBuild["prerender"]; - export const publicPath: ServerBuild["publicPath"]; - export const routeDiscovery: ServerBuild["routeDiscovery"]; - export const routes: ServerBuild["routes"]; - export const ssr: ServerBuild["ssr"]; - export const allowedActionOrigins: ServerBuild["allowedActionOrigins"]; - export const unstable_getCriticalCss: ServerBuild["unstable_getCriticalCss"]; -} \ No newline at end of file diff --git a/web/frontend/.react-router/types/app/+types/root.ts b/web/frontend/.react-router/types/app/+types/root.ts deleted file mode 100644 index 5bd414e1..00000000 --- a/web/frontend/.react-router/types/app/+types/root.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Generated by React Router - -import type { GetInfo, GetAnnotations } from "react-router/internal"; - -type Module = typeof import("../root.js") - -type Info = GetInfo<{ - file: "root.tsx", - module: Module -}> - -type Matches = [{ - id: "root"; - module: typeof import("../root.js"); -}]; - -type Annotations = GetAnnotations; - -export namespace Route { - // links - export type LinkDescriptors = Annotations["LinkDescriptors"]; - export type LinksFunction = Annotations["LinksFunction"]; - - // meta - export type MetaArgs = Annotations["MetaArgs"]; - export type MetaDescriptors = Annotations["MetaDescriptors"]; - export type MetaFunction = Annotations["MetaFunction"]; - - // headers - export type HeadersArgs = Annotations["HeadersArgs"]; - export type HeadersFunction = Annotations["HeadersFunction"]; - - // middleware - export type MiddlewareFunction = Annotations["MiddlewareFunction"]; - - // clientMiddleware - export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; - - // loader - export type LoaderArgs = Annotations["LoaderArgs"]; - - // clientLoader - export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; - - // action - export type ActionArgs = Annotations["ActionArgs"]; - - // clientAction - export type ClientActionArgs = Annotations["ClientActionArgs"]; - - // HydrateFallback - export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; - - // Component - export type ComponentProps = Annotations["ComponentProps"]; - - // ErrorBoundary - export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; -} \ No newline at end of file diff --git a/web/frontend/.react-router/types/app/routes/+types/beer-styles.ts b/web/frontend/.react-router/types/app/routes/+types/beer-styles.ts deleted file mode 100644 index f2c79512..00000000 --- a/web/frontend/.react-router/types/app/routes/+types/beer-styles.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Generated by React Router - -import type { GetInfo, GetAnnotations } from "react-router/internal"; - -type Module = typeof import("../beer-styles.js") - -type Info = GetInfo<{ - file: "routes/beer-styles.tsx", - module: Module -}> - -type Matches = [{ - id: "root"; - module: typeof import("../../root.js"); -}, { - id: "routes/beer-styles"; - module: typeof import("../beer-styles.js"); -}]; - -type Annotations = GetAnnotations; - -export namespace Route { - // links - export type LinkDescriptors = Annotations["LinkDescriptors"]; - export type LinksFunction = Annotations["LinksFunction"]; - - // meta - export type MetaArgs = Annotations["MetaArgs"]; - export type MetaDescriptors = Annotations["MetaDescriptors"]; - export type MetaFunction = Annotations["MetaFunction"]; - - // headers - export type HeadersArgs = Annotations["HeadersArgs"]; - export type HeadersFunction = Annotations["HeadersFunction"]; - - // middleware - export type MiddlewareFunction = Annotations["MiddlewareFunction"]; - - // clientMiddleware - export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; - - // loader - export type LoaderArgs = Annotations["LoaderArgs"]; - - // clientLoader - export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; - - // action - export type ActionArgs = Annotations["ActionArgs"]; - - // clientAction - export type ClientActionArgs = Annotations["ClientActionArgs"]; - - // HydrateFallback - export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; - - // Component - export type ComponentProps = Annotations["ComponentProps"]; - - // ErrorBoundary - export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; -} \ No newline at end of file diff --git a/web/frontend/.react-router/types/app/routes/+types/beers.ts b/web/frontend/.react-router/types/app/routes/+types/beers.ts deleted file mode 100644 index ab2f29bd..00000000 --- a/web/frontend/.react-router/types/app/routes/+types/beers.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Generated by React Router - -import type { GetInfo, GetAnnotations } from "react-router/internal"; - -type Module = typeof import("../beers.js") - -type Info = GetInfo<{ - file: "routes/beers.tsx", - module: Module -}> - -type Matches = [{ - id: "root"; - module: typeof import("../../root.js"); -}, { - id: "routes/beers"; - module: typeof import("../beers.js"); -}]; - -type Annotations = GetAnnotations; - -export namespace Route { - // links - export type LinkDescriptors = Annotations["LinkDescriptors"]; - export type LinksFunction = Annotations["LinksFunction"]; - - // meta - export type MetaArgs = Annotations["MetaArgs"]; - export type MetaDescriptors = Annotations["MetaDescriptors"]; - export type MetaFunction = Annotations["MetaFunction"]; - - // headers - export type HeadersArgs = Annotations["HeadersArgs"]; - export type HeadersFunction = Annotations["HeadersFunction"]; - - // middleware - export type MiddlewareFunction = Annotations["MiddlewareFunction"]; - - // clientMiddleware - export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; - - // loader - export type LoaderArgs = Annotations["LoaderArgs"]; - - // clientLoader - export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; - - // action - export type ActionArgs = Annotations["ActionArgs"]; - - // clientAction - export type ClientActionArgs = Annotations["ClientActionArgs"]; - - // HydrateFallback - export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; - - // Component - export type ComponentProps = Annotations["ComponentProps"]; - - // ErrorBoundary - export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; -} \ No newline at end of file diff --git a/web/frontend/.react-router/types/app/routes/+types/breweries.ts b/web/frontend/.react-router/types/app/routes/+types/breweries.ts deleted file mode 100644 index 6eb78fca..00000000 --- a/web/frontend/.react-router/types/app/routes/+types/breweries.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Generated by React Router - -import type { GetInfo, GetAnnotations } from "react-router/internal"; - -type Module = typeof import("../breweries.js") - -type Info = GetInfo<{ - file: "routes/breweries.tsx", - module: Module -}> - -type Matches = [{ - id: "root"; - module: typeof import("../../root.js"); -}, { - id: "routes/breweries"; - module: typeof import("../breweries.js"); -}]; - -type Annotations = GetAnnotations; - -export namespace Route { - // links - export type LinkDescriptors = Annotations["LinkDescriptors"]; - export type LinksFunction = Annotations["LinksFunction"]; - - // meta - export type MetaArgs = Annotations["MetaArgs"]; - export type MetaDescriptors = Annotations["MetaDescriptors"]; - export type MetaFunction = Annotations["MetaFunction"]; - - // headers - export type HeadersArgs = Annotations["HeadersArgs"]; - export type HeadersFunction = Annotations["HeadersFunction"]; - - // middleware - export type MiddlewareFunction = Annotations["MiddlewareFunction"]; - - // clientMiddleware - export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; - - // loader - export type LoaderArgs = Annotations["LoaderArgs"]; - - // clientLoader - export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; - - // action - export type ActionArgs = Annotations["ActionArgs"]; - - // clientAction - export type ClientActionArgs = Annotations["ClientActionArgs"]; - - // HydrateFallback - export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; - - // Component - export type ComponentProps = Annotations["ComponentProps"]; - - // ErrorBoundary - export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; -} \ No newline at end of file diff --git a/web/frontend/.react-router/types/app/routes/+types/confirm.ts b/web/frontend/.react-router/types/app/routes/+types/confirm.ts deleted file mode 100644 index 48c2aeb6..00000000 --- a/web/frontend/.react-router/types/app/routes/+types/confirm.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Generated by React Router - -import type { GetInfo, GetAnnotations } from "react-router/internal"; - -type Module = typeof import("../confirm.js") - -type Info = GetInfo<{ - file: "routes/confirm.tsx", - module: Module -}> - -type Matches = [{ - id: "root"; - module: typeof import("../../root.js"); -}, { - id: "routes/confirm"; - module: typeof import("../confirm.js"); -}]; - -type Annotations = GetAnnotations; - -export namespace Route { - // links - export type LinkDescriptors = Annotations["LinkDescriptors"]; - export type LinksFunction = Annotations["LinksFunction"]; - - // meta - export type MetaArgs = Annotations["MetaArgs"]; - export type MetaDescriptors = Annotations["MetaDescriptors"]; - export type MetaFunction = Annotations["MetaFunction"]; - - // headers - export type HeadersArgs = Annotations["HeadersArgs"]; - export type HeadersFunction = Annotations["HeadersFunction"]; - - // middleware - export type MiddlewareFunction = Annotations["MiddlewareFunction"]; - - // clientMiddleware - export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; - - // loader - export type LoaderArgs = Annotations["LoaderArgs"]; - - // clientLoader - export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; - - // action - export type ActionArgs = Annotations["ActionArgs"]; - - // clientAction - export type ClientActionArgs = Annotations["ClientActionArgs"]; - - // HydrateFallback - export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; - - // Component - export type ComponentProps = Annotations["ComponentProps"]; - - // ErrorBoundary - export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; -} \ No newline at end of file diff --git a/web/frontend/.react-router/types/app/routes/+types/dashboard.ts b/web/frontend/.react-router/types/app/routes/+types/dashboard.ts deleted file mode 100644 index b63995af..00000000 --- a/web/frontend/.react-router/types/app/routes/+types/dashboard.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Generated by React Router - -import type { GetInfo, GetAnnotations } from "react-router/internal"; - -type Module = typeof import("../dashboard.js") - -type Info = GetInfo<{ - file: "routes/dashboard.tsx", - module: Module -}> - -type Matches = [{ - id: "root"; - module: typeof import("../../root.js"); -}, { - id: "routes/dashboard"; - module: typeof import("../dashboard.js"); -}]; - -type Annotations = GetAnnotations; - -export namespace Route { - // links - export type LinkDescriptors = Annotations["LinkDescriptors"]; - export type LinksFunction = Annotations["LinksFunction"]; - - // meta - export type MetaArgs = Annotations["MetaArgs"]; - export type MetaDescriptors = Annotations["MetaDescriptors"]; - export type MetaFunction = Annotations["MetaFunction"]; - - // headers - export type HeadersArgs = Annotations["HeadersArgs"]; - export type HeadersFunction = Annotations["HeadersFunction"]; - - // middleware - export type MiddlewareFunction = Annotations["MiddlewareFunction"]; - - // clientMiddleware - export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; - - // loader - export type LoaderArgs = Annotations["LoaderArgs"]; - - // clientLoader - export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; - - // action - export type ActionArgs = Annotations["ActionArgs"]; - - // clientAction - export type ClientActionArgs = Annotations["ClientActionArgs"]; - - // HydrateFallback - export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; - - // Component - export type ComponentProps = Annotations["ComponentProps"]; - - // ErrorBoundary - export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; -} \ No newline at end of file diff --git a/web/frontend/.react-router/types/app/routes/+types/home.ts b/web/frontend/.react-router/types/app/routes/+types/home.ts deleted file mode 100644 index e49d62af..00000000 --- a/web/frontend/.react-router/types/app/routes/+types/home.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Generated by React Router - -import type { GetInfo, GetAnnotations } from "react-router/internal"; - -type Module = typeof import("../home.js") - -type Info = GetInfo<{ - file: "routes/home.tsx", - module: Module -}> - -type Matches = [{ - id: "root"; - module: typeof import("../../root.js"); -}, { - id: "routes/home"; - module: typeof import("../home.js"); -}]; - -type Annotations = GetAnnotations; - -export namespace Route { - // links - export type LinkDescriptors = Annotations["LinkDescriptors"]; - export type LinksFunction = Annotations["LinksFunction"]; - - // meta - export type MetaArgs = Annotations["MetaArgs"]; - export type MetaDescriptors = Annotations["MetaDescriptors"]; - export type MetaFunction = Annotations["MetaFunction"]; - - // headers - export type HeadersArgs = Annotations["HeadersArgs"]; - export type HeadersFunction = Annotations["HeadersFunction"]; - - // middleware - export type MiddlewareFunction = Annotations["MiddlewareFunction"]; - - // clientMiddleware - export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; - - // loader - export type LoaderArgs = Annotations["LoaderArgs"]; - - // clientLoader - export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; - - // action - export type ActionArgs = Annotations["ActionArgs"]; - - // clientAction - export type ClientActionArgs = Annotations["ClientActionArgs"]; - - // HydrateFallback - export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; - - // Component - export type ComponentProps = Annotations["ComponentProps"]; - - // ErrorBoundary - export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; -} \ No newline at end of file diff --git a/web/frontend/.react-router/types/app/routes/+types/login.ts b/web/frontend/.react-router/types/app/routes/+types/login.ts deleted file mode 100644 index 382b47e5..00000000 --- a/web/frontend/.react-router/types/app/routes/+types/login.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Generated by React Router - -import type { GetInfo, GetAnnotations } from "react-router/internal"; - -type Module = typeof import("../login.js") - -type Info = GetInfo<{ - file: "routes/login.tsx", - module: Module -}> - -type Matches = [{ - id: "root"; - module: typeof import("../../root.js"); -}, { - id: "routes/login"; - module: typeof import("../login.js"); -}]; - -type Annotations = GetAnnotations; - -export namespace Route { - // links - export type LinkDescriptors = Annotations["LinkDescriptors"]; - export type LinksFunction = Annotations["LinksFunction"]; - - // meta - export type MetaArgs = Annotations["MetaArgs"]; - export type MetaDescriptors = Annotations["MetaDescriptors"]; - export type MetaFunction = Annotations["MetaFunction"]; - - // headers - export type HeadersArgs = Annotations["HeadersArgs"]; - export type HeadersFunction = Annotations["HeadersFunction"]; - - // middleware - export type MiddlewareFunction = Annotations["MiddlewareFunction"]; - - // clientMiddleware - export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; - - // loader - export type LoaderArgs = Annotations["LoaderArgs"]; - - // clientLoader - export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; - - // action - export type ActionArgs = Annotations["ActionArgs"]; - - // clientAction - export type ClientActionArgs = Annotations["ClientActionArgs"]; - - // HydrateFallback - export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; - - // Component - export type ComponentProps = Annotations["ComponentProps"]; - - // ErrorBoundary - export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; -} \ No newline at end of file diff --git a/web/frontend/.react-router/types/app/routes/+types/logout.ts b/web/frontend/.react-router/types/app/routes/+types/logout.ts deleted file mode 100644 index e8d9a012..00000000 --- a/web/frontend/.react-router/types/app/routes/+types/logout.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Generated by React Router - -import type { GetInfo, GetAnnotations } from "react-router/internal"; - -type Module = typeof import("../logout.js") - -type Info = GetInfo<{ - file: "routes/logout.tsx", - module: Module -}> - -type Matches = [{ - id: "root"; - module: typeof import("../../root.js"); -}, { - id: "routes/logout"; - module: typeof import("../logout.js"); -}]; - -type Annotations = GetAnnotations; - -export namespace Route { - // links - export type LinkDescriptors = Annotations["LinkDescriptors"]; - export type LinksFunction = Annotations["LinksFunction"]; - - // meta - export type MetaArgs = Annotations["MetaArgs"]; - export type MetaDescriptors = Annotations["MetaDescriptors"]; - export type MetaFunction = Annotations["MetaFunction"]; - - // headers - export type HeadersArgs = Annotations["HeadersArgs"]; - export type HeadersFunction = Annotations["HeadersFunction"]; - - // middleware - export type MiddlewareFunction = Annotations["MiddlewareFunction"]; - - // clientMiddleware - export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; - - // loader - export type LoaderArgs = Annotations["LoaderArgs"]; - - // clientLoader - export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; - - // action - export type ActionArgs = Annotations["ActionArgs"]; - - // clientAction - export type ClientActionArgs = Annotations["ClientActionArgs"]; - - // HydrateFallback - export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; - - // Component - export type ComponentProps = Annotations["ComponentProps"]; - - // ErrorBoundary - export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; -} \ No newline at end of file diff --git a/web/frontend/.react-router/types/app/routes/+types/register.ts b/web/frontend/.react-router/types/app/routes/+types/register.ts deleted file mode 100644 index 59817344..00000000 --- a/web/frontend/.react-router/types/app/routes/+types/register.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Generated by React Router - -import type { GetInfo, GetAnnotations } from "react-router/internal"; - -type Module = typeof import("../register.js") - -type Info = GetInfo<{ - file: "routes/register.tsx", - module: Module -}> - -type Matches = [{ - id: "root"; - module: typeof import("../../root.js"); -}, { - id: "routes/register"; - module: typeof import("../register.js"); -}]; - -type Annotations = GetAnnotations; - -export namespace Route { - // links - export type LinkDescriptors = Annotations["LinkDescriptors"]; - export type LinksFunction = Annotations["LinksFunction"]; - - // meta - export type MetaArgs = Annotations["MetaArgs"]; - export type MetaDescriptors = Annotations["MetaDescriptors"]; - export type MetaFunction = Annotations["MetaFunction"]; - - // headers - export type HeadersArgs = Annotations["HeadersArgs"]; - export type HeadersFunction = Annotations["HeadersFunction"]; - - // middleware - export type MiddlewareFunction = Annotations["MiddlewareFunction"]; - - // clientMiddleware - export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; - - // loader - export type LoaderArgs = Annotations["LoaderArgs"]; - - // clientLoader - export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; - - // action - export type ActionArgs = Annotations["ActionArgs"]; - - // clientAction - export type ClientActionArgs = Annotations["ClientActionArgs"]; - - // HydrateFallback - export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; - - // Component - export type ComponentProps = Annotations["ComponentProps"]; - - // ErrorBoundary - export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; -} \ No newline at end of file diff --git a/web/frontend/.react-router/types/app/routes/+types/theme.ts b/web/frontend/.react-router/types/app/routes/+types/theme.ts deleted file mode 100644 index 1d059536..00000000 --- a/web/frontend/.react-router/types/app/routes/+types/theme.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Generated by React Router - -import type { GetInfo, GetAnnotations } from "react-router/internal"; - -type Module = typeof import("../theme.js") - -type Info = GetInfo<{ - file: "routes/theme.tsx", - module: Module -}> - -type Matches = [{ - id: "root"; - module: typeof import("../../root.js"); -}, { - id: "routes/theme"; - module: typeof import("../theme.js"); -}]; - -type Annotations = GetAnnotations; - -export namespace Route { - // links - export type LinkDescriptors = Annotations["LinkDescriptors"]; - export type LinksFunction = Annotations["LinksFunction"]; - - // meta - export type MetaArgs = Annotations["MetaArgs"]; - export type MetaDescriptors = Annotations["MetaDescriptors"]; - export type MetaFunction = Annotations["MetaFunction"]; - - // headers - export type HeadersArgs = Annotations["HeadersArgs"]; - export type HeadersFunction = Annotations["HeadersFunction"]; - - // middleware - export type MiddlewareFunction = Annotations["MiddlewareFunction"]; - - // clientMiddleware - export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"]; - - // loader - export type LoaderArgs = Annotations["LoaderArgs"]; - - // clientLoader - export type ClientLoaderArgs = Annotations["ClientLoaderArgs"]; - - // action - export type ActionArgs = Annotations["ActionArgs"]; - - // clientAction - export type ClientActionArgs = Annotations["ClientActionArgs"]; - - // HydrateFallback - export type HydrateFallbackProps = Annotations["HydrateFallbackProps"]; - - // Component - export type ComponentProps = Annotations["ComponentProps"]; - - // ErrorBoundary - export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"]; -} \ No newline at end of file diff --git a/web/frontend/.storybook/main.ts b/web/frontend/.storybook/main.ts index 39122717..c2600398 100644 --- a/web/frontend/.storybook/main.ts +++ b/web/frontend/.storybook/main.ts @@ -1,50 +1,55 @@ import type { StorybookConfig } from '@storybook/react-vite'; const config: StorybookConfig = { - stories: [ - '../stories/Configure.mdx', - '../stories/SubmitButton.stories.tsx', - '../stories/FormField.stories.tsx', - '../stories/Navbar.stories.tsx', - '../stories/Toast.stories.tsx', - '../stories/Themes.stories.tsx', - ], - addons: [ - '@chromatic-com/storybook', - '@storybook/addon-vitest', - '@storybook/addon-a11y', - '@storybook/addon-docs', - '@storybook/addon-onboarding', - ], - framework: '@storybook/react-vite', - async viteFinal(config) { - config.plugins = (config.plugins ?? []).filter((plugin) => { - if (!plugin) { - return true; - } + stories: [ + '../stories/Configure.mdx', + '../stories/SubmitButton.stories.tsx', + '../stories/FormField.stories.tsx', + '../stories/SectionCard.stories.tsx', + '../stories/UsernameSection.stories.tsx', + '../stories/PasswordSection.stories.tsx', + '../stories/LoginForm.stories.tsx', + '../stories/RegisterForm.stories.tsx', + '../stories/Navbar.stories.tsx', + '../stories/Toast.stories.tsx', + '../stories/Themes.stories.tsx', + ], + addons: [ + '@chromatic-com/storybook', + '@storybook/addon-vitest', + '@storybook/addon-a11y', + '@storybook/addon-docs', + '@storybook/addon-onboarding', + ], + framework: '@storybook/react-vite', + async viteFinal(config) { + config.plugins = (config.plugins ?? []).filter((plugin) => { + if (!plugin) { + return true; + } - const pluginName = typeof plugin === 'object' && 'name' in plugin ? plugin.name : ''; - return !pluginName.startsWith('react-router'); - }); + const pluginName = typeof plugin === 'object' && 'name' in plugin ? plugin.name : ''; + return !pluginName.startsWith('react-router'); + }); - config.build ??= {}; - config.build.rollupOptions ??= {}; + config.build ??= {}; + config.build.rollupOptions ??= {}; - const previousOnWarn = config.build.rollupOptions.onwarn; - config.build.rollupOptions.onwarn = (warning, warn) => { - if (warning.code === 'MODULE_LEVEL_DIRECTIVE') { - return; - } + const previousOnWarn = config.build.rollupOptions.onwarn; + config.build.rollupOptions.onwarn = (warning, warn) => { + if (warning.code === 'MODULE_LEVEL_DIRECTIVE') { + return; + } - if (typeof previousOnWarn === 'function') { - previousOnWarn(warning, warn); - return; - } + if (typeof previousOnWarn === 'function') { + previousOnWarn(warning, warn); + return; + } - warn(warning); - }; + warn(warning); + }; - return config; - }, + return config; + }, }; export default config; diff --git a/web/frontend/.storybook/preview-head.html b/web/frontend/.storybook/preview-head.html index 3ed76734..d450bb10 100644 --- a/web/frontend/.storybook/preview-head.html +++ b/web/frontend/.storybook/preview-head.html @@ -1,6 +1,6 @@ diff --git a/web/frontend/.storybook/preview.ts b/web/frontend/.storybook/preview.ts index 223a6ba5..f12622a1 100644 --- a/web/frontend/.storybook/preview.ts +++ b/web/frontend/.storybook/preview.ts @@ -2,62 +2,71 @@ import type { Preview } from '@storybook/react-vite'; import { createElement } from 'react'; import { MemoryRouter } from 'react-router'; import '../app/app.css'; -import { biergartenThemes, defaultThemeName, isBiergartenTheme } from '../app/lib/themes'; +import { + biergartenThemes, + defaultThemeName, + isBiergartenTheme, +} from '../app/features/theme/themes'; const preview: Preview = { - globalTypes: { - theme: { - description: 'Active Biergarten theme', - toolbar: { - title: 'Theme', - icon: 'paintbrush', - dynamicTitle: true, - items: biergartenThemes.map((theme) => ({ - value: theme.value, - title: theme.label, - })), - }, - }, - }, - initialGlobals: { - theme: defaultThemeName, - }, - decorators: [ - (Story, context) => { - const theme = isBiergartenTheme(String(context.globals.theme)) - ? context.globals.theme - : defaultThemeName; + globalTypes: { + theme: { + description: 'Active Biergarten theme', + toolbar: { + title: 'Theme', + icon: 'paintbrush', + dynamicTitle: true, + items: biergartenThemes.map((theme) => ({ + value: theme.value, + title: theme.label, + })), + }, + }, + }, + initialGlobals: { + theme: defaultThemeName, + }, + decorators: [ + (Story, context) => { + const theme = isBiergartenTheme(String(context.globals.theme)) + ? context.globals.theme + : defaultThemeName; - return createElement( - MemoryRouter, - undefined, - createElement( - 'div', - { - 'data-theme': theme, - className: 'bg-base-200 p-6 text-base-content', - }, - createElement('div', { className: 'mx-auto max-w-7xl' }, createElement(Story)), - ), - ); - }, - ], - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - layout: 'padded', + const content = createElement( + 'div', + { + 'data-theme': theme, + className: 'bg-base-200 p-6 text-base-content', + }, + createElement('div', { className: 'mx-auto max-w-7xl' }, createElement(Story)), + ); - a11y: { - // 'todo' - show a11y violations in the test UI only - // 'error' - fail CI on a11y violations - // 'off' - skip a11y checks entirely - test: 'todo', - }, - }, + // Stories driving `useSubmit`/`useNavigation` bring their own data + // router (createMemoryRouter + RouterProvider), which can't nest + // inside another . + if (context.parameters.usesDataRouter) { + return content; + } + + return createElement(MemoryRouter, undefined, content); + }, + ], + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + layout: 'padded', + + a11y: { + // 'todo' - show a11y violations in the test UI only + // 'error' - fail CI on a11y violations + // 'off' - skip a11y checks entirely + test: 'todo', + }, + }, }; export default preview; diff --git a/web/frontend/app/app.css b/web/frontend/app/app.css index 97129e49..8967dbd6 100644 --- a/web/frontend/app/app.css +++ b/web/frontend/app/app.css @@ -1,13 +1,13 @@ @import 'tailwindcss'; @plugin "daisyui" { - themes: biergarten-lager, biergarten-stout, biergarten-cassis, biergarten-weizen; + themes: biergarten-lager, biergarten-stout, biergarten-cassis, biergarten-weizen; } @theme { - --font-sans: - 'DM Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', - 'Segoe UI Symbol', 'Noto Color Emoji'; - --font-serif: 'Volkhov', ui-serif, Georgia, serif; + --font-sans: + 'DM Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-serif: 'Volkhov', ui-serif, Georgia, serif; } h1, @@ -17,7 +17,7 @@ h4, h5, h6, .card-title { - font-family: var(--font-serif); + font-family: var(--font-serif); } /* ───────────────────────────────────────── @@ -26,55 +26,55 @@ h6, primary, softened mahogany secondary. ───────────────────────────────────────── */ @plugin "daisyui/theme" { - name: 'biergarten-lager'; - default: true; - prefersdark: false; - color-scheme: 'light'; + name: 'biergarten-lager'; + default: true; + prefersdark: false; + color-scheme: 'light'; - --color-base-100: oklch(96% 0.012 82); /* warm parchment */ - --color-base-200: oklch(92% 0.018 80); /* brushed paper */ - --color-base-300: oklch(87% 0.025 78); /* tinted linen */ - --color-base-content: oklch(28% 0.025 58); /* dark brown ink — 15.6:1 on base-100 */ + --color-base-100: oklch(96% 0.012 82); /* warm parchment */ + --color-base-200: oklch(92% 0.018 80); /* brushed paper */ + --color-base-300: oklch(87% 0.025 78); /* tinted linen */ + --color-base-content: oklch(28% 0.025 58); /* dark brown ink — 15.6:1 on base-100 */ - --color-primary: oklch(65% 0.085 62); /* mellow amber */ - --color-primary-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */ + --color-primary: oklch(65% 0.085 62); /* mellow amber */ + --color-primary-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */ - --color-secondary: oklch(42% 0.05 42); /* softened mahogany */ - --color-secondary-content: oklch(96% 0.01 76); /* off-white — 14.2:1 on secondary */ + --color-secondary: oklch(42% 0.05 42); /* softened mahogany */ + --color-secondary-content: oklch(96% 0.01 76); /* off-white — 14.2:1 on secondary */ - --color-accent: oklch(93% 0.015 90); /* frothy cream */ - --color-accent-content: oklch(28% 0.025 58); /* dark brown — 12.8:1 on accent */ + --color-accent: oklch(93% 0.015 90); /* frothy cream */ + --color-accent-content: oklch(28% 0.025 58); /* dark brown — 12.8:1 on accent */ - --color-neutral: oklch(28% 0.02 46); /* warm roast dark */ - --color-neutral-content: oklch(92% 0.012 80); /* pale parchment — 12.0:1 on neutral */ + --color-neutral: oklch(28% 0.02 46); /* warm roast dark */ + --color-neutral-content: oklch(92% 0.012 80); /* pale parchment — 12.0:1 on neutral */ - --color-info: oklch(46% 0.065 145); /* muted hop green */ - --color-info-content: oklch(97% 0.008 145); /* near-white — 14.2:1 on info */ + --color-info: oklch(46% 0.065 145); /* muted hop green */ + --color-info-content: oklch(97% 0.008 145); /* near-white — 14.2:1 on info */ - --color-success: oklch(70% 0.06 122); /* soft barley gold */ - --color-success-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */ + --color-success: oklch(70% 0.06 122); /* soft barley gold */ + --color-success-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */ - --color-warning: oklch(72% 0.09 56); /* toned amber */ - --color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */ + --color-warning: oklch(72% 0.09 56); /* toned amber */ + --color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */ - --color-error: oklch(54% 0.09 22); /* restrained cherry */ - --color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */ + --color-error: oklch(54% 0.09 22); /* restrained cherry */ + --color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */ - --color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */ - --color-surface-content: oklch(28% 0.025 58); /* dark brown — 9.2:1 on surface */ + --color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */ + --color-surface-content: oklch(28% 0.025 58); /* dark brown — 9.2:1 on surface */ - --color-muted: oklch(42% 0.055 62); /* amber-brown — 14.2:1 on base-100, 8.3:1 on surface */ - --color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */ - --color-highlight-content: oklch(22% 0.025 55); /* dark brown — 4.9:1 on highlight */ + --color-muted: oklch(42% 0.055 62); /* amber-brown — 14.2:1 on base-100, 8.3:1 on surface */ + --color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */ + --color-highlight-content: oklch(22% 0.025 55); /* dark brown — 4.9:1 on highlight */ - --radius-selector: 0.375rem; - --radius-field: 0.5rem; - --radius-box: 0.875rem; - --size-selector: 0.25rem; - --size-field: 0.25rem; - --border: 1px; - --depth: 1; - --noise: 1; + --radius-selector: 0.375rem; + --radius-field: 0.5rem; + --radius-box: 0.875rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 1; + --noise: 1; } /* ───────────────────────────────────────── @@ -83,55 +83,55 @@ h6, primary, deep mahogany secondary. ───────────────────────────────────────── */ @plugin "daisyui/theme" { - name: 'biergarten-stout'; - default: false; - prefersdark: true; - color-scheme: 'dark'; + name: 'biergarten-stout'; + default: false; + prefersdark: true; + color-scheme: 'dark'; - --color-base-100: oklch(14% 0.006 45); /* charred barrel black */ - --color-base-200: oklch(18% 0.008 43); /* roasted malt dark */ - --color-base-300: oklch(23% 0.01 42); /* deep brown */ - --color-base-content: oklch(88% 0.008 75); /* warm off-white — 9.4:1 on base-100 */ + --color-base-100: oklch(14% 0.006 45); /* charred barrel black */ + --color-base-200: oklch(18% 0.008 43); /* roasted malt dark */ + --color-base-300: oklch(23% 0.01 42); /* deep brown */ + --color-base-content: oklch(88% 0.008 75); /* warm off-white — 9.4:1 on base-100 */ - --color-primary: oklch(68% 0.055 60); /* golden amber */ - --color-primary-content: oklch(92% 0.012 50); /* warm off-white — 4.6:1 on primary */ + --color-primary: oklch(68% 0.055 60); /* golden amber */ + --color-primary-content: oklch(92% 0.012 50); /* warm off-white — 4.6:1 on primary */ - --color-secondary: oklch(48% 0.035 40); /* deep mahogany ale */ - --color-secondary-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */ + --color-secondary: oklch(48% 0.035 40); /* deep mahogany ale */ + --color-secondary-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */ - --color-accent: oklch(82% 0.01 88); /* frothy cream head */ - --color-accent-content: oklch(18% 0.012 55); /* near-black — 6.2:1 on accent */ + --color-accent: oklch(82% 0.01 88); /* frothy cream head */ + --color-accent-content: oklch(18% 0.012 55); /* near-black — 6.2:1 on accent */ - --color-neutral: oklch(20% 0.008 45); /* near-black with warmth */ - --color-neutral-content: oklch(88% 0.007 78); /* warm off-white — 9.3:1 on neutral */ + --color-neutral: oklch(20% 0.008 45); /* near-black with warmth */ + --color-neutral-content: oklch(88% 0.007 78); /* warm off-white — 9.3:1 on neutral */ - --color-info: oklch(60% 0.04 145); /* cool hop green */ - --color-info-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */ + --color-info: oklch(60% 0.04 145); /* cool hop green */ + --color-info-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */ - --color-success: oklch(66% 0.038 120); /* fresh barley */ - --color-success-content: oklch(90% 0.012 120); /* pale barley-white — 4.6:1 on success */ + --color-success: oklch(66% 0.038 120); /* fresh barley */ + --color-success-content: oklch(90% 0.012 120); /* pale barley-white — 4.6:1 on success */ - --color-warning: oklch(70% 0.055 55); /* amber harvest */ - --color-warning-content: oklch(94% 0.012 55); /* warm near-white — 4.7:1 on warning */ + --color-warning: oklch(70% 0.055 55); /* amber harvest */ + --color-warning-content: oklch(94% 0.012 55); /* warm near-white — 4.7:1 on warning */ - --color-error: oklch(50% 0.06 20); /* deep cherry kriek */ - --color-error-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */ + --color-error: oklch(50% 0.06 20); /* deep cherry kriek */ + --color-error-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */ - --color-surface: oklch(26% 0.012 45); /* elevated dark panel */ - --color-surface-content: oklch(88% 0.008 75); /* warm off-white — 9.2:1 on surface */ + --color-surface: oklch(26% 0.012 45); /* elevated dark panel */ + --color-surface-content: oklch(88% 0.008 75); /* warm off-white — 9.2:1 on surface */ - --color-muted: oklch(78% 0.018 72); /* warm grey — 4.7:1 on base-100, 4.6:1 on surface */ - --color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */ - --color-highlight-content: oklch(88% 0.008 75); /* warm off-white — 9.0:1 on highlight */ + --color-muted: oklch(78% 0.018 72); /* warm grey — 4.7:1 on base-100, 4.6:1 on surface */ + --color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */ + --color-highlight-content: oklch(88% 0.008 75); /* warm off-white — 9.0:1 on highlight */ - --radius-selector: 0.375rem; - --radius-field: 0.5rem; - --radius-box: 0.875rem; - --size-selector: 0.25rem; - --size-field: 0.25rem; - --border: 1px; - --depth: 1; - --noise: 1; + --radius-selector: 0.375rem; + --radius-field: 0.5rem; + --radius-box: 0.875rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 1; + --noise: 1; } /* ───────────────────────────────────────── @@ -140,57 +140,57 @@ h6, primary, sour cherry secondary. ───────────────────────────────────────── */ @plugin "daisyui/theme" { - name: 'biergarten-cassis'; - default: false; - prefersdark: false; - color-scheme: 'dark'; + name: 'biergarten-cassis'; + default: false; + prefersdark: false; + color-scheme: 'dark'; - --color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */ - --color-base-200: oklch(17% 0.013 292); /* deep purple-black */ - --color-base-300: oklch(22% 0.016 290); /* dark grape */ - --color-base-content: oklch(90% 0.014 300); /* pale lavender-white — 10.7:1 on base-100 */ + --color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */ + --color-base-200: oklch(17% 0.013 292); /* deep purple-black */ + --color-base-300: oklch(22% 0.016 290); /* dark grape */ + --color-base-content: oklch(90% 0.014 300); /* pale lavender-white — 10.7:1 on base-100 */ - --color-primary: oklch(72% 0.075 295); /* cassis berry purple */ - --color-primary-content: oklch(95% 0.01 295); /* pale lavender — 4.5:1 on primary */ + --color-primary: oklch(72% 0.075 295); /* cassis berry purple */ + --color-primary-content: oklch(95% 0.01 295); /* pale lavender — 4.5:1 on primary */ - --color-secondary: oklch(68% 0.06 10); /* sour cherry rose */ - --color-secondary-content: oklch(92% 0.006 10); /* warm near-white — 4.6:1 on secondary */ + --color-secondary: oklch(68% 0.06 10); /* sour cherry rose */ + --color-secondary-content: oklch(92% 0.006 10); /* warm near-white — 4.6:1 on secondary */ - --color-accent: oklch(75% 0.045 130); /* tart lime zest */ - --color-accent-content: oklch(98.5% 0.01 130); /* near-white — 4.8:1 on accent */ + --color-accent: oklch(75% 0.045 130); /* tart lime zest */ + --color-accent-content: oklch(98.5% 0.01 130); /* near-white — 4.8:1 on accent */ - --color-neutral: oklch(18% 0.016 290); /* deep blackened grape */ - --color-neutral-content: oklch(88% 0.01 295); /* pale lavender — 9.3:1 on neutral */ + --color-neutral: oklch(18% 0.016 290); /* deep blackened grape */ + --color-neutral-content: oklch(88% 0.01 295); /* pale lavender — 9.3:1 on neutral */ - --color-info: oklch(62% 0.04 250); /* muted indigo */ - --color-info-content: oklch(88% 0.008 250); /* pale indigo-white — 4.8:1 on info */ + --color-info: oklch(62% 0.04 250); /* muted indigo */ + --color-info-content: oklch(88% 0.008 250); /* pale indigo-white — 4.8:1 on info */ - --color-success: oklch(65% 0.04 145); /* elderberry green */ - --color-success-content: oklch(90% 0.008 145); /* pale green-white — 4.7:1 on success */ + --color-success: oklch(65% 0.04 145); /* elderberry green */ + --color-success-content: oklch(90% 0.008 145); /* pale green-white — 4.7:1 on success */ - --color-warning: oklch(70% 0.05 65); /* sour apricot */ - --color-warning-content: oklch(97% 0.03 65); /* near-white — 5.6:1 on warning */ + --color-warning: oklch(70% 0.05 65); /* sour apricot */ + --color-warning-content: oklch(97% 0.03 65); /* near-white — 5.6:1 on warning */ - --color-error: oklch(50% 0.055 22); /* kriek red */ - --color-error-content: oklch(97% 0.006 22); /* near-white — 13.2:1 on error */ + --color-error: oklch(50% 0.055 22); /* kriek red */ + --color-error-content: oklch(97% 0.006 22); /* near-white — 13.2:1 on error */ - --color-surface: oklch(27% 0.022 292); /* lifted purple-black panel */ - --color-surface-content: oklch(90% 0.014 300); /* pale lavender-white — 10.4:1 on surface */ + --color-surface: oklch(27% 0.022 292); /* lifted purple-black panel */ + --color-surface-content: oklch(90% 0.014 300); /* pale lavender-white — 10.4:1 on surface */ - --color-muted: oklch( - 77.6% 0.022 300 - ); /* desaturated lavender — 4.6:1 on base-100, 4.5:1 on surface */ - --color-highlight: oklch(35% 0.04 295); /* cassis-tinted hover and active state */ - --color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white — 10.1:1 on highlight */ + --color-muted: oklch( + 77.6% 0.022 300 + ); /* desaturated lavender — 4.6:1 on base-100, 4.5:1 on surface */ + --color-highlight: oklch(35% 0.04 295); /* cassis-tinted hover and active state */ + --color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white — 10.1:1 on highlight */ - --radius-selector: 0.5rem; - --radius-field: 0.5rem; - --radius-box: 1rem; - --size-selector: 0.25rem; - --size-field: 0.25rem; - --border: 1px; - --depth: 1; - --noise: 1; + --radius-selector: 0.5rem; + --radius-field: 0.5rem; + --radius-box: 1rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 1; + --noise: 1; } /* ───────────────────────────────────────── @@ -199,53 +199,53 @@ h6, fresh-cut barley primary, sage secondary. ───────────────────────────────────────── */ @plugin "daisyui/theme" { - name: 'biergarten-weizen'; - default: false; - prefersdark: false; - color-scheme: 'light'; + name: 'biergarten-weizen'; + default: false; + prefersdark: false; + color-scheme: 'light'; - --color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */ - --color-base-200: oklch(96% 0.012 114); /* pale barley wash */ - --color-base-300: oklch(92% 0.019 116); /* light straw */ - --color-base-content: oklch(20% 0.022 122); /* deep green-black — 19.5:1 on base-100 */ + --color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */ + --color-base-200: oklch(96% 0.012 114); /* pale barley wash */ + --color-base-300: oklch(92% 0.019 116); /* light straw */ + --color-base-content: oklch(20% 0.022 122); /* deep green-black — 19.5:1 on base-100 */ - --color-primary: oklch(52% 0.085 118); /* fresh-cut barley green */ - --color-primary-content: oklch(97% 0.005 118); /* near-white — 12.5:1 on primary */ + --color-primary: oklch(52% 0.085 118); /* fresh-cut barley green */ + --color-primary-content: oklch(97% 0.005 118); /* near-white — 12.5:1 on primary */ - --color-secondary: oklch(44% 0.055 128); /* muted sage stem */ - --color-secondary-content: oklch(97% 0.005 128); /* near-white — 14.8:1 on secondary */ + --color-secondary: oklch(44% 0.055 128); /* muted sage stem */ + --color-secondary-content: oklch(97% 0.005 128); /* near-white — 14.8:1 on secondary */ - --color-accent: oklch(93% 0.03 148); /* pale morning dew */ - --color-accent-content: oklch(22% 0.022 148); /* deep green — 13.4:1 on accent */ + --color-accent: oklch(93% 0.03 148); /* pale morning dew */ + --color-accent-content: oklch(22% 0.022 148); /* deep green — 13.4:1 on accent */ - --color-neutral: oklch(76% 0.028 118); /* dried straw, surface differentiation */ - --color-neutral-content: oklch(98.9% 0.005 118); /* near-white — 4.6:1 on neutral */ + --color-neutral: oklch(76% 0.028 118); /* dried straw, surface differentiation */ + --color-neutral-content: oklch(98.9% 0.005 118); /* near-white — 4.6:1 on neutral */ - --color-info: oklch(38% 0.065 232); /* clear summer sky */ - --color-info-content: oklch(98% 0.005 232); /* near-white — 16.8:1 on info */ + --color-info: oklch(38% 0.065 232); /* clear summer sky */ + --color-info-content: oklch(98% 0.005 232); /* near-white — 16.8:1 on info */ - --color-success: oklch(38% 0.085 145); /* young shoot green */ - --color-success-content: oklch(98% 0.005 145); /* near-white — 16.8:1 on success */ + --color-success: oklch(38% 0.085 145); /* young shoot green */ + --color-success-content: oklch(98% 0.005 145); /* near-white — 16.8:1 on success */ - --color-warning: oklch(68% 0.1 76); /* ripening grain amber */ - --color-warning-content: oklch(92.5% 0.005 72); /* warm near-white — 4.5:1 on warning */ + --color-warning: oklch(68% 0.1 76); /* ripening grain amber */ + --color-warning-content: oklch(92.5% 0.005 72); /* warm near-white — 4.5:1 on warning */ - --color-error: oklch(52% 0.1 18); /* dusty rose red */ - --color-error-content: oklch(98% 0.005 15); /* near-white — 12.5:1 on error */ + --color-error: oklch(52% 0.1 18); /* dusty rose red */ + --color-error-content: oklch(98% 0.005 15); /* near-white — 12.5:1 on error */ - --color-surface: oklch(94% 0.012 112); /* soft barley-wash panel */ - --color-surface-content: oklch(20% 0.022 122); /* deep green-black — 14.0:1 on surface */ + --color-surface: oklch(94% 0.012 112); /* soft barley-wash panel */ + --color-surface-content: oklch(20% 0.022 122); /* deep green-black — 14.0:1 on surface */ - --color-muted: oklch(38% 0.055 120); /* sage green — 18.1:1 on base-100, 13.0:1 on surface */ - --color-highlight: oklch(85% 0.04 118); /* green-tinted hover and active state */ - --color-highlight-content: oklch(20% 0.022 122); /* deep green-black — 7.8:1 on highlight */ + --color-muted: oklch(38% 0.055 120); /* sage green — 18.1:1 on base-100, 13.0:1 on surface */ + --color-highlight: oklch(85% 0.04 118); /* green-tinted hover and active state */ + --color-highlight-content: oklch(20% 0.022 122); /* deep green-black — 7.8:1 on highlight */ - --radius-selector: 2rem; - --radius-field: 2rem; - --radius-box: 1rem; - --size-selector: 0.25rem; - --size-field: 0.25rem; - --border: 1px; - --depth: 0; - --noise: 0; + --radius-selector: 2rem; + --radius-field: 2rem; + --radius-box: 1rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 0; + --noise: 0; } diff --git a/web/frontend/app/components/Navbar.tsx b/web/frontend/app/components/Navbar.tsx deleted file mode 100644 index c4c47a38..00000000 --- a/web/frontend/app/components/Navbar.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import { - Disclosure, - DisclosureButton, - DisclosurePanel, - Menu, - MenuButton, - MenuItem, - MenuItems, -} from '@headlessui/react'; -import { Link } from 'react-router'; - -interface NavbarProps { - auth: { - username: string; - accessToken: string; - refreshToken: string; - userAccountId: string; - } | null; -} - -export default function Navbar({ auth }: NavbarProps) { - const navLinks = [ - { to: '/theme', label: 'Theme' }, - { to: '/beers', label: 'Beers' }, - { to: '/breweries', label: 'Breweries' }, - { to: '/beer-styles', label: 'Beer Styles' }, - ]; - - return ( - - {({ open }) => ( - <> -
-
- - - {open ? ( - - ) : ( - - )} - - - - - 🍺 The Biergarten App - -
- -
- {navLinks.map((link) => ( - - {link.label} - - ))} -
- -
- {!auth && ( - - Register User - - )} - - {auth ? ( - <> - - Dashboard - - - - - {auth.username} - - - - {({ focus }) => ( - - Dashboard - - )} - - - {({ focus }) => ( - - Logout - - )} - - - - - ) : ( - - Login - - )} -
-
- - -
- {navLinks.map((link) => ( - - {link.label} - - ))} - {!auth && ( - - Register User - - )} -
-
- - )} -
- ); -} diff --git a/web/frontend/app/components/forms/FormField.tsx b/web/frontend/app/components/forms/FormField.tsx deleted file mode 100644 index ae542729..00000000 --- a/web/frontend/app/components/forms/FormField.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Description, Field, Label } from '@headlessui/react'; - -type FormFieldProps = React.InputHTMLAttributes & { - label: string; - error?: string; - hint?: string; - labelClassName?: string; - inputClassName?: string; - hintClassName?: string; -}; - -export default function FormField({ - label, - error, - hint, - className, - labelClassName, - inputClassName, - hintClassName, - ...inputProps -}: FormFieldProps) { - return ( - - - - - - {error ? ( - {error} - ) : hint ? ( - {hint} - ) : null} - - ); -} diff --git a/web/frontend/app/components/forms/SubmitButton.tsx b/web/frontend/app/components/forms/SubmitButton.tsx deleted file mode 100644 index aacbdfed..00000000 --- a/web/frontend/app/components/forms/SubmitButton.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Button } from '@headlessui/react'; - -interface SubmitButtonProps { - isSubmitting: boolean; - idleText: string; - submittingText: string; - className?: string; -} - -export default function SubmitButton({ - isSubmitting, - idleText, - submittingText, - className, -}: SubmitButtonProps) { - return ( - - ); -} diff --git a/web/frontend/app/components/toast/ToastProvider.tsx b/web/frontend/app/components/toast/ToastProvider.tsx deleted file mode 100644 index c5e3e0f3..00000000 --- a/web/frontend/app/components/toast/ToastProvider.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Toaster } from 'react-hot-toast'; - -export default function ToastProvider() { - return ( - - ); -} diff --git a/web/frontend/app/components/ui/forms/FormField.tsx b/web/frontend/app/components/ui/forms/FormField.tsx new file mode 100644 index 00000000..a454a1c6 --- /dev/null +++ b/web/frontend/app/components/ui/forms/FormField.tsx @@ -0,0 +1,42 @@ +import { Description, Field, Label } from '@headlessui/react'; + +type FormFieldProps = React.InputHTMLAttributes & { + label: string; + error?: string; + hint?: string; + labelClassName?: string; + inputClassName?: string; + hintClassName?: string; +}; + +const FormField = ({ + label, + error, + hint, + className, + labelClassName, + inputClassName, + hintClassName, + ...inputProps +}: FormFieldProps) => { + return ( + + + + + + {error ? ( + {error} + ) : hint ? ( + {hint} + ) : null} + + ); +}; + +export default FormField; diff --git a/web/frontend/app/components/ui/forms/SubmitButton.tsx b/web/frontend/app/components/ui/forms/SubmitButton.tsx new file mode 100644 index 00000000..0033b0f2 --- /dev/null +++ b/web/frontend/app/components/ui/forms/SubmitButton.tsx @@ -0,0 +1,28 @@ +import { Button } from '@headlessui/react'; + +interface SubmitButtonProps { + isSubmitting: boolean; + idleText: string; + submittingText: string; + className?: string; +} + +const SubmitButton = ({ isSubmitting, idleText, submittingText, className }: SubmitButtonProps) => { + return ( + + ); +}; + +export default SubmitButton; diff --git a/web/frontend/app/components/ui/navbar/NavLinks.tsx b/web/frontend/app/components/ui/navbar/NavLinks.tsx new file mode 100644 index 00000000..ac4f0617 --- /dev/null +++ b/web/frontend/app/components/ui/navbar/NavLinks.tsx @@ -0,0 +1,44 @@ +import { Link } from 'react-router'; + +const navLinks = [ + { to: '/theme', label: 'Theme' }, + { to: '/beers', label: 'Beers' }, + { to: '/breweries', label: 'Breweries' }, + { to: '/beer-styles', label: 'Beer Styles' }, +]; + +interface NavLinksProps { + variant: 'desktop' | 'mobile'; + showRegister: boolean; +} + +const NavLinks = ({ variant, showRegister }: NavLinksProps) => { + if (variant === 'desktop') { + return ( +
+ {navLinks.map((link) => ( + + {link.label} + + ))} +
+ ); + } + + return ( +
+ {navLinks.map((link) => ( + + {link.label} + + ))} + {showRegister && ( + + Register User + + )} +
+ ); +}; + +export default NavLinks; diff --git a/web/frontend/app/components/ui/navbar/Navbar.tsx b/web/frontend/app/components/ui/navbar/Navbar.tsx new file mode 100644 index 00000000..1583b794 --- /dev/null +++ b/web/frontend/app/components/ui/navbar/Navbar.tsx @@ -0,0 +1,93 @@ +import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react'; +import { Link } from 'react-router'; +import NavLinks from './NavLinks'; +import UserMenu from './UserMenu'; + +interface NavbarProps { + auth: { + username: string; + accessToken: string; + refreshToken: string; + userAccountId: string; + } | null; +} + +const Navbar = ({ auth }: NavbarProps) => ( + + {({ open }) => ( + <> +
+
+ + + {open ? ( + + ) : ( + + )} + + + + + 🍺 The Biergarten App + +
+ + + +
+ {!auth && ( + + Register User + + )} + + {auth ? ( + <> + + Dashboard + + + + + ) : ( + + Login + + )} +
+
+ + + + + + )} +
+); + +export default Navbar; diff --git a/web/frontend/app/components/ui/navbar/UserMenu.tsx b/web/frontend/app/components/ui/navbar/UserMenu.tsx new file mode 100644 index 00000000..7f9f8809 --- /dev/null +++ b/web/frontend/app/components/ui/navbar/UserMenu.tsx @@ -0,0 +1,30 @@ +import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'; +import { Link } from 'react-router'; + +interface UserMenuProps { + username: string; +} + +const UserMenu = ({ username }: UserMenuProps) => ( + + {username} + + + {({ focus }) => ( + + Dashboard + + )} + + + {({ focus }) => ( + + Logout + + )} + + + +); + +export default UserMenu; diff --git a/web/frontend/app/components/ui/toast/ToastProvider.tsx b/web/frontend/app/components/ui/toast/ToastProvider.tsx new file mode 100644 index 00000000..da7b1a04 --- /dev/null +++ b/web/frontend/app/components/ui/toast/ToastProvider.tsx @@ -0,0 +1,28 @@ +import { Toaster } from 'react-hot-toast'; + +const ToastProvider = () => { + return ( + + ); +}; + +export default ToastProvider; diff --git a/web/frontend/app/components/toast/toast.ts b/web/frontend/app/components/ui/toast/toast.ts similarity index 100% rename from web/frontend/app/components/toast/toast.ts rename to web/frontend/app/components/ui/toast/toast.ts diff --git a/web/frontend/app/features/account/components/AuthFlowDemoCard.tsx b/web/frontend/app/features/account/components/AuthFlowDemoCard.tsx new file mode 100644 index 00000000..37898699 --- /dev/null +++ b/web/frontend/app/features/account/components/AuthFlowDemoCard.tsx @@ -0,0 +1,76 @@ +import { Link } from 'react-router'; + +const AuthFlowDemoCard = () => ( +
+
+

Auth Flow Demo

+

+ This demo showcases the following authentication features: +

+
    +
  • +
    +

    Login

    +

    + POST to /api/auth/login with + username & password +

    +
    +
  • +
  • +
    +

    Register

    +

    + POST to /api/auth/register with full + user details +

    +
    +
  • +
  • +
    +

    Session

    +

    + JWT access & refresh tokens stored in an HTTP-only cookie +

    +
    +
  • +
  • +
    +

    Protected Routes

    +

    + This dashboard requires authentication via{' '} + requireAuth() +

    +
    +
  • +
  • +
    +

    Token Refresh

    +

    + POST to /api/auth/refresh with the + refresh token in an X-Refresh-Token{' '} + header +

    +
    +
  • +
  • +
    +

    Account Management

    +

    + PATCH /api/auth/username,{' '} + /email,{' '} + /password,{' '} + /profile, and DELETE{' '} + /account — see{' '} + + Account Settings + +

    +
    +
  • +
+
+
+); + +export default AuthFlowDemoCard; diff --git a/web/frontend/app/features/account/components/DeleteAccountSection.tsx b/web/frontend/app/features/account/components/DeleteAccountSection.tsx new file mode 100644 index 00000000..bac9d623 --- /dev/null +++ b/web/frontend/app/features/account/components/DeleteAccountSection.tsx @@ -0,0 +1,98 @@ +import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react'; +import { Trash, WarningTriangle } from 'iconoir-react'; +import { useState } from 'react'; +import { useNavigation, useSubmit } from 'react-router'; +import { useSectionError } from '../hooks/useSectionError'; +import type { ActionResult } from '../types'; + +interface DeleteAccountSectionProps { + userAccountId: string; + result: ActionResult | undefined; +} + +const DeleteAccountSection = ({ userAccountId, result }: DeleteAccountSectionProps) => { + const navigation = useNavigation(); + const submit = useSubmit(); + const [dialogOpen, setDialogOpen] = useState(false); + const isSubmitting = navigation.formData?.get('intent') === 'delete'; + + useSectionError(result, 'delete'); + + return ( + <> +
+
+
+
+ + +
+

Delete Account

+

+ Permanently delete your account. This cannot be undone. +

+
+
+ +
+
+
+ + setDialogOpen(false)} + className="relative z-50" + > + + + ); +}; + +export default DeleteAccountSection; diff --git a/web/frontend/app/features/account/components/EmailSection.tsx b/web/frontend/app/features/account/components/EmailSection.tsx new file mode 100644 index 00000000..16d3d0f3 --- /dev/null +++ b/web/frontend/app/features/account/components/EmailSection.tsx @@ -0,0 +1,57 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Mail } from 'iconoir-react'; +import { useForm } from 'react-hook-form'; +import { useNavigation, useSubmit } from 'react-router'; +import FormField from '../../../components/ui/forms/FormField'; +import SubmitButton from '../../../components/ui/forms/SubmitButton'; +import { useSectionError } from '../hooks/useSectionError'; +import { updateEmailSchema, type UpdateEmailSchema } from '../schemas'; +import type { SectionProps } from '../types'; +import SectionCard from './SectionCard'; + +const EmailSection = ({ email, open, onToggle, result }: SectionProps & { email: string }) => { + const navigation = useNavigation(); + const submit = useSubmit(); + const isSubmitting = navigation.formData?.get('intent') === 'email'; + + const emailForm = useForm({ + resolver: zodResolver(updateEmailSchema), + defaultValues: { newEmail: email }, + }); + + useSectionError(result, 'email'); + + return ( + + ); +}; + +export default EmailSection; diff --git a/web/frontend/app/features/account/components/PasswordSection.tsx b/web/frontend/app/features/account/components/PasswordSection.tsx new file mode 100644 index 00000000..404b7d3a --- /dev/null +++ b/web/frontend/app/features/account/components/PasswordSection.tsx @@ -0,0 +1,75 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Lock } from 'iconoir-react'; +import { useForm } from 'react-hook-form'; +import { useNavigation, useSubmit } from 'react-router'; +import FormField from '../../../components/ui/forms/FormField'; +import SubmitButton from '../../../components/ui/forms/SubmitButton'; +import { useSectionError } from '../hooks/useSectionError'; +import { updatePasswordSchema, type UpdatePasswordSchema } from '../schemas'; +import type { SectionProps } from '../types'; +import SectionCard from './SectionCard'; + +const PasswordSection = ({ open, onToggle, result }: SectionProps) => { + const navigation = useNavigation(); + const submit = useSubmit(); + const isSubmitting = navigation.formData?.get('intent') === 'password'; + + const passwordForm = useForm({ + resolver: zodResolver(updatePasswordSchema), + }); + + useSectionError(result, 'password'); + + return ( + + ); +}; + +export default PasswordSection; diff --git a/web/frontend/app/features/account/components/ProfileSection.tsx b/web/frontend/app/features/account/components/ProfileSection.tsx new file mode 100644 index 00000000..3dcf52ad --- /dev/null +++ b/web/frontend/app/features/account/components/ProfileSection.tsx @@ -0,0 +1,82 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { User } from 'iconoir-react'; +import { useForm } from 'react-hook-form'; +import { useNavigation, useSubmit } from 'react-router'; +import FormField from '../../../components/ui/forms/FormField'; +import SubmitButton from '../../../components/ui/forms/SubmitButton'; +import { useSectionError } from '../hooks/useSectionError'; +import { updateProfileSchema, type UpdateProfileSchema } from '../schemas'; +import type { SectionProps } from '../types'; +import SectionCard from './SectionCard'; + +const ProfileSection = ({ + firstName, + lastName, + dateOfBirth, + open, + onToggle, + result, +}: SectionProps & { firstName: string; lastName: string; dateOfBirth: string }) => { + const navigation = useNavigation(); + const submit = useSubmit(); + const isSubmitting = navigation.formData?.get('intent') === 'profile'; + + const profileForm = useForm({ + resolver: zodResolver(updateProfileSchema), + defaultValues: { firstName, lastName, dateOfBirth }, + }); + + useSectionError(result, 'profile'); + + return ( + + ); +}; + +export default ProfileSection; diff --git a/web/frontend/app/features/account/components/SectionCard.tsx b/web/frontend/app/features/account/components/SectionCard.tsx new file mode 100644 index 00000000..b8d108ad --- /dev/null +++ b/web/frontend/app/features/account/components/SectionCard.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react'; + +interface SectionCardProps { + icon: ReactNode; + title: string; + description: string; + open: boolean; + onToggle: () => void; + children: ReactNode; +} + +const SectionCard = ({ icon, title, description, open, onToggle, children }: SectionCardProps) => ( +
+
+
+
+ {icon} +
+

{title}

+

{description}

+
+
+ +
+ {open &&
{children}
} +
+
+); + +export default SectionCard; diff --git a/web/frontend/app/features/account/components/SessionInfoCard.tsx b/web/frontend/app/features/account/components/SessionInfoCard.tsx new file mode 100644 index 00000000..4c7d4981 --- /dev/null +++ b/web/frontend/app/features/account/components/SessionInfoCard.tsx @@ -0,0 +1,42 @@ +import { Link } from 'react-router'; + +interface SessionInfoCardProps { + username: string; + userAccountId: string; +} + +const SessionInfoCard = ({ username, userAccountId }: SessionInfoCardProps) => ( +
+
+

Welcome, {username}!

+

+ You are successfully authenticated. This is a protected page that requires a valid + session. +

+ +
+

+ Session Info +

+
+
+
Username
+
{username}
+
+
+
User ID
+
{userAccountId}
+
+
+
+ +
+ + Account Settings + +
+
+
+); + +export default SessionInfoCard; diff --git a/web/frontend/app/features/account/components/UsernameSection.tsx b/web/frontend/app/features/account/components/UsernameSection.tsx new file mode 100644 index 00000000..d7ba7061 --- /dev/null +++ b/web/frontend/app/features/account/components/UsernameSection.tsx @@ -0,0 +1,61 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { User } from 'iconoir-react'; +import { useForm } from 'react-hook-form'; +import { useNavigation, useSubmit } from 'react-router'; +import FormField from '../../../components/ui/forms/FormField'; +import SubmitButton from '../../../components/ui/forms/SubmitButton'; +import { useSectionError } from '../hooks/useSectionError'; +import { updateUsernameSchema, type UpdateUsernameSchema } from '../schemas'; +import type { SectionProps } from '../types'; +import SectionCard from './SectionCard'; + +const UsernameSection = ({ + username, + open, + onToggle, + result, +}: SectionProps & { username: string }) => { + const navigation = useNavigation(); + const submit = useSubmit(); + const isSubmitting = navigation.formData?.get('intent') === 'username'; + + const usernameForm = useForm({ + resolver: zodResolver(updateUsernameSchema), + defaultValues: { newUsername: username }, + }); + + useSectionError(result, 'username'); + + return ( + + ); +}; + +export default UsernameSection; diff --git a/web/frontend/app/features/account/hooks/useSectionError.ts b/web/frontend/app/features/account/hooks/useSectionError.ts new file mode 100644 index 00000000..b34274b7 --- /dev/null +++ b/web/frontend/app/features/account/hooks/useSectionError.ts @@ -0,0 +1,10 @@ +import { useEffect } from 'react'; +import { showErrorToast } from '../../../components/ui/toast/toast'; +import type { ActionResult } from '../types'; + +export const useSectionError = (result: ActionResult | undefined, intent: ActionResult['intent']) => { + useEffect(() => { + if (!result || result.intent !== intent) return; + showErrorToast(result.error); + }, [result, intent]); +}; diff --git a/web/frontend/app/features/account/routes/account.tsx b/web/frontend/app/features/account/routes/account.tsx new file mode 100644 index 00000000..1c685fa4 --- /dev/null +++ b/web/frontend/app/features/account/routes/account.tsx @@ -0,0 +1,269 @@ +import { Settings } from 'iconoir-react'; +import { useEffect, useReducer, useState } from 'react'; +import { data, Link, redirect, useSearchParams } from 'react-router'; +import { showSuccessToast } from '../../../components/ui/toast/toast'; +import { + commitSession, + deleteAccount, + destroySession, + getSession, + getUserAccount, + requireAuth, + updateEmail, + updatePassword, + updateProfile, + updateUsername, +} from '../../auth/auth.server'; +import DeleteAccountSection from '../components/DeleteAccountSection'; +import EmailSection from '../components/EmailSection'; +import PasswordSection from '../components/PasswordSection'; +import ProfileSection from '../components/ProfileSection'; +import UsernameSection from '../components/UsernameSection'; +import { + updateEmailSchema, + updatePasswordSchema, + updateProfileSchema, + updateUsernameSchema, +} from '../schemas'; +import { initialSectionState, sectionReducer } from '../state'; +import type { ActionResult } from '../types'; + +import type { Route } from './+types/account'; + +export const meta = ({}: Route.MetaArgs) => [{ title: 'Account Settings | The Biergarten App' }]; + +export const loader = async ({ request }: Route.LoaderArgs) => { + const auth = await requireAuth(request); + + let details = { firstName: '', lastName: '', email: '', dateOfBirth: '' }; + try { + const account = await getUserAccount(auth.userAccountId); + details = { + firstName: account.firstName, + lastName: account.lastName, + email: account.email, + // Trim the backend's ISO datetime down to the yyyy-MM-dd a date input expects. + dateOfBirth: account.dateOfBirth.slice(0, 10), + }; + } catch { + // Fall through with blank fields; the update forms still work standalone. + } + + return { username: auth.username, userAccountId: auth.userAccountId, ...details }; +}; + +const redirectWithToast = (message: string, headers?: HeadersInit) => + redirect(`/account?toast=${encodeURIComponent(message)}`, headers ? { headers } : undefined); + +export const action = async ({ request }: Route.ActionArgs) => { + const auth = await requireAuth(request); + const formData = await request.formData(); + const intent = formData.get('intent'); + + switch (intent) { + case 'delete': { + try { + await deleteAccount(auth.accessToken); + } catch (err) { + return { + intent: 'delete' as const, + error: err instanceof Error ? err.message : 'Failed to delete account.', + }; + } + const session = await getSession(request); + return redirect('/', { headers: { 'Set-Cookie': await destroySession(session) } }); + } + + case 'username': { + const result = updateUsernameSchema.safeParse({ + newUsername: formData.get('newUsername'), + }); + if (!result.success) return { intent, error: result.error.issues[0].message } as const; + + try { + const payload = await updateUsername(auth.accessToken, result.data.newUsername); + const session = await getSession(request); + session.set('username', payload.username); + return redirectWithToast('Username updated successfully.', { + 'Set-Cookie': await commitSession(session), + }); + } catch (err) { + return { + intent, + error: err instanceof Error ? err.message : 'Failed to update username.', + } as const; + } + } + + case 'email': { + const result = updateEmailSchema.safeParse({ newEmail: formData.get('newEmail') }); + if (!result.success) return { intent, error: result.error.issues[0].message } as const; + + try { + const payload = await updateEmail(auth.accessToken, result.data.newEmail); + return redirectWithToast( + payload.emailConfirmed + ? 'Email updated successfully.' + : 'Email updated. Please re-confirm your new address.', + ); + } catch (err) { + return { + intent, + error: err instanceof Error ? err.message : 'Failed to update email.', + } as const; + } + } + + case 'profile': { + const result = updateProfileSchema.safeParse({ + firstName: formData.get('firstName'), + lastName: formData.get('lastName'), + dateOfBirth: formData.get('dateOfBirth'), + }); + if (!result.success) return { intent, error: result.error.issues[0].message } as const; + + try { + await updateProfile( + auth.accessToken, + result.data.firstName, + result.data.lastName, + result.data.dateOfBirth, + ); + return redirectWithToast('Profile updated successfully.'); + } catch (err) { + return { + intent, + error: err instanceof Error ? err.message : 'Failed to update profile.', + } as const; + } + } + + case 'password': { + const result = updatePasswordSchema.safeParse({ + currentPassword: formData.get('currentPassword'), + newPassword: formData.get('newPassword'), + confirmNewPassword: formData.get('confirmNewPassword'), + }); + if (!result.success) return { intent, error: result.error.issues[0].message } as const; + + try { + await updatePassword( + auth.accessToken, + result.data.currentPassword, + result.data.newPassword, + ); + return redirectWithToast('Password changed successfully.'); + } catch (err) { + return { + intent, + error: err instanceof Error ? err.message : 'Failed to update password.', + } as const; + } + } + + default: + throw data('Unknown request.', { status: 400 }); + } +}; + +const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => { + const { username, userAccountId, firstName, lastName, email, dateOfBirth } = loaderData; + const result = actionData as ActionResult | undefined; + + const [sections, dispatch] = useReducer(sectionReducer, initialSectionState); + // Bumped once per successful update so the section subtree below remounts + // with fresh `key`s, picking up the new loader values (and, for + // PasswordSection, clearing fields that have no persisted value to key + // off) without needing an effect to imperatively reset each form. + const [resetKey, setResetKey] = useState(0); + const [searchParams, setSearchParams] = useSearchParams(); + const toast = searchParams.get('toast'); + + // Collapsing the sections and bumping resetKey are state adjustments in + // response to a changed prop (the `toast` search param), so they happen + // directly during render - React's documented alternative to an effect + // for this case - guarded by `handledToast` so it only runs once per + // redirect. Showing the toast and clearing the param are genuine side + // effects on an external system (react-hot-toast, the URL) and stay in + // the effect below. + const [handledToast, setHandledToast] = useState(null); + if (toast && toast !== handledToast) { + setHandledToast(toast); + dispatch({ type: 'CLOSE_ALL' }); + setResetKey((key) => key + 1); + } + + useEffect(() => { + if (!toast) return; + showSuccessToast(toast); + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.delete('toast'); + return next; + }, + { replace: true }, + ); + }, [toast, setSearchParams]); + + return ( +
+
+
+
+ + dispatch({ type: 'TOGGLE_USERNAME_VISIBILITY' })} + result={result} + /> + + dispatch({ type: 'TOGGLE_EMAIL_VISIBILITY' })} + result={result} + /> + + dispatch({ type: 'TOGGLE_PROFILE_VISIBILITY' })} + result={result} + /> + + dispatch({ type: 'TOGGLE_PASSWORD_VISIBILITY' })} + result={result} + /> + + + + + ← Back to dashboard + +
+
+ ); +}; + +export default AccountPage; diff --git a/web/frontend/app/features/account/routes/dashboard.tsx b/web/frontend/app/features/account/routes/dashboard.tsx new file mode 100644 index 00000000..ad51bf3f --- /dev/null +++ b/web/frontend/app/features/account/routes/dashboard.tsx @@ -0,0 +1,31 @@ +import { requireAuth } from '../../auth/auth.server'; +import AuthFlowDemoCard from '../components/AuthFlowDemoCard'; +import SessionInfoCard from '../components/SessionInfoCard'; +import type { Route } from './+types/dashboard'; + +export const meta = ({}: Route.MetaArgs) => { + return [{ title: 'Dashboard | The Biergarten App' }]; +}; + +export const loader = async ({ request }: Route.LoaderArgs) => { + const auth = await requireAuth(request); + return { + username: auth.username, + userAccountId: auth.userAccountId, + }; +}; + +const Dashboard = ({ loaderData }: Route.ComponentProps) => { + const { username, userAccountId } = loaderData; + + return ( +
+
+ + +
+
+ ); +}; + +export default Dashboard; diff --git a/web/frontend/app/features/account/schemas.ts b/web/frontend/app/features/account/schemas.ts new file mode 100644 index 00000000..74c660b2 --- /dev/null +++ b/web/frontend/app/features/account/schemas.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; + +export const updateUsernameSchema = z.object({ + newUsername: z + .string() + .min(3, 'Username must be at least 3 characters') + .max(64, 'Username must be at most 64 characters') + .regex(/^[a-zA-Z0-9._-]+$/, 'Only letters, numbers, dots, underscores, and hyphens'), +}); + +export type UpdateUsernameSchema = z.infer; + +export const updateEmailSchema = z.object({ + newEmail: z.string().email('Invalid email address').max(128, 'Email is too long'), +}); + +export type UpdateEmailSchema = z.infer; + +export const updateProfileSchema = z.object({ + firstName: z.string().min(1, 'First name is required').max(128, 'First name is too long'), + lastName: z.string().min(1, 'Last name is required').max(128, 'Last name is too long'), + dateOfBirth: z.string().min(1, 'Date of birth is required'), +}); + +export type UpdateProfileSchema = z.infer; + +export const updatePasswordSchema = z + .object({ + currentPassword: z.string().min(1, 'Current password is required'), + newPassword: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex(/[A-Z]/, 'Password must contain an uppercase letter') + .regex(/[a-z]/, 'Password must contain a lowercase letter') + .regex(/[0-9]/, 'Password must contain a number') + .regex(/[^a-zA-Z0-9]/, 'Password must contain a special character'), + confirmNewPassword: z.string().min(1, 'Please confirm your new password'), + }) + .refine((data) => data.newPassword === data.confirmNewPassword, { + message: 'Passwords must match', + path: ['confirmNewPassword'], + }) + .refine((data) => data.newPassword !== data.currentPassword, { + message: 'New password must be different from the current password', + path: ['newPassword'], + }); + +export type UpdatePasswordSchema = z.infer; diff --git a/web/frontend/app/features/account/state.ts b/web/frontend/app/features/account/state.ts new file mode 100644 index 00000000..2b92f792 --- /dev/null +++ b/web/frontend/app/features/account/state.ts @@ -0,0 +1,25 @@ +import type { SectionAction, SectionState } from './types'; + +export const initialSectionState: SectionState = { + usernameOpen: false, + emailOpen: false, + profileOpen: false, + passwordOpen: false, +}; + +export const sectionReducer = (state: SectionState, action: SectionAction): SectionState => { + switch (action.type) { + case 'TOGGLE_USERNAME_VISIBILITY': + return { ...initialSectionState, usernameOpen: !state.usernameOpen }; + case 'TOGGLE_EMAIL_VISIBILITY': + return { ...initialSectionState, emailOpen: !state.emailOpen }; + case 'TOGGLE_PROFILE_VISIBILITY': + return { ...initialSectionState, profileOpen: !state.profileOpen }; + case 'TOGGLE_PASSWORD_VISIBILITY': + return { ...initialSectionState, passwordOpen: !state.passwordOpen }; + case 'CLOSE_ALL': + return initialSectionState; + default: + return state; + } +}; diff --git a/web/frontend/app/features/account/types.ts b/web/frontend/app/features/account/types.ts new file mode 100644 index 00000000..22ac9703 --- /dev/null +++ b/web/frontend/app/features/account/types.ts @@ -0,0 +1,31 @@ +// Success never reaches the component as action data - every intent redirects +// back to this route (with a `?toast=` message) instead of returning a +// payload, so the only shape an action can hand back here is an error. +export interface ActionResult { + intent: 'username' | 'email' | 'profile' | 'password' | 'delete'; + error: string; +} + +export interface SectionState { + usernameOpen: boolean; + emailOpen: boolean; + profileOpen: boolean; + passwordOpen: boolean; +} + +export type ActionType = + | 'TOGGLE_USERNAME_VISIBILITY' + | 'TOGGLE_EMAIL_VISIBILITY' + | 'TOGGLE_PROFILE_VISIBILITY' + | 'TOGGLE_PASSWORD_VISIBILITY' + | 'CLOSE_ALL'; + +export interface SectionAction { + type: ActionType; +} + +export interface SectionProps { + open: boolean; + onToggle: () => void; + result: ActionResult | undefined; +} diff --git a/web/frontend/app/features/auth/auth.server.ts b/web/frontend/app/features/auth/auth.server.ts new file mode 100644 index 00000000..e3835974 --- /dev/null +++ b/web/frontend/app/features/auth/auth.server.ts @@ -0,0 +1,267 @@ +import { createCookieSessionStorage, redirect } from 'react-router'; + +const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080'; + +export interface AuthTokens { + accessToken: string; + refreshToken: string; + userAccountId: string; + username: string; +} + +interface ApiResponse { + message: string; + payload: T; +} + +interface LoginPayload { + userAccountId: string; + username: string; + refreshToken: string; + accessToken: string; +} + +interface RegistrationPayload extends LoginPayload { + confirmationEmailSent: boolean; +} + +interface UpdateUsernamePayload { + userAccountId: string; + username: string; +} + +interface UpdateEmailPayload { + userAccountId: string; + email: string; + emailConfirmed: boolean; +} + +interface UpdateProfilePayload { + userAccountId: string; + firstName: string; + lastName: string; + dateOfBirth: string; +} + +interface UpdatePasswordPayload { + userAccountId: string; + changedAt: string; +} + +export interface UserAccountDetails { + username: string; + firstName: string; + lastName: string; + email: string; + dateOfBirth: string; +} + +const sessionStorage = createCookieSessionStorage({ + cookie: { + name: '__session', + httpOnly: true, + maxAge: 60 * 60 * 24 * 21, // 21 days (matches refresh token) + path: '/', + sameSite: 'lax', + secrets: [process.env.SESSION_SECRET || 'dev-secret-change-me'], + secure: process.env.NODE_ENV === 'production', + }, +}); + +export const getSession = async (request: Request) => { + return sessionStorage.getSession(request.headers.get('Cookie')); +}; + +export const commitSession = async (session: Awaited>) => { + return sessionStorage.commitSession(session); +}; + +export const destroySession = async (session: Awaited>) => { + return sessionStorage.destroySession(session); +}; + +export const requireAuth = async (request: Request): Promise => { + const session = await getSession(request); + const accessToken = session.get('accessToken'); + const refreshToken = session.get('refreshToken'); + + if (!accessToken || !refreshToken) { + throw redirect('/login'); + } + + return { + accessToken, + refreshToken, + userAccountId: session.get('userAccountId'), + username: session.get('username'), + }; +}; + +export const getOptionalAuth = async (request: Request): Promise => { + const session = await getSession(request); + const accessToken = session.get('accessToken'); + + if (!accessToken) return null; + + return { + accessToken, + refreshToken: session.get('refreshToken'), + userAccountId: session.get('userAccountId'), + username: session.get('username'), + }; +}; + +export const login = async (username: string, password: string) => { + const res = await fetch(`${API_BASE_URL}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Login failed (${res.status})`); + } + + const data: ApiResponse = await res.json(); + return data.payload; +}; + +export const register = async (body: { + username: string; + firstName: string; + lastName: string; + email: string; + dateOfBirth: string; + password: string; +}) => { + const res = await fetch(`${API_BASE_URL}/api/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Registration failed (${res.status})`); + } + + const data: ApiResponse = await res.json(); + return data.payload; +}; + +export const refreshTokens = async (refreshToken: string) => { + const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, { + method: 'POST', + headers: { 'X-Refresh-Token': refreshToken }, + }); + + if (!res.ok) { + throw new Error('Token refresh failed'); + } + + const data: ApiResponse = await res.json(); + return data.payload; +}; + +export const getUserAccount = async (userAccountId: string): Promise => { + const res = await fetch(`${API_BASE_URL}/api/user/${userAccountId}`); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Failed to load account details (${res.status})`); + } + + return res.json(); +}; + +export const confirmEmail = async (token: string, accessToken: string) => { + const res = await fetch(`${API_BASE_URL}/api/auth/confirm?token=${encodeURIComponent(token)}`, { + method: 'POST', + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Confirmation failed (${res.status})`); + } + + const data: ApiResponse<{ userAccountId: string; confirmedDate: string }> = await res.json(); + return data.payload; +}; + +const authorizedRequest = async ( + path: string, + accessToken: string, + init: { method: string; body?: unknown }, +): Promise => { + const res = await fetch(`${API_BASE_URL}${path}`, { + method: init.method, + headers: { + Authorization: `Bearer ${accessToken}`, + ...(init.body ? { 'Content-Type': 'application/json' } : {}), + }, + body: init.body ? JSON.stringify(init.body) : undefined, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Request failed (${res.status})`); + } + + const data: ApiResponse = await res.json(); + return data.payload; +}; + +export const updateUsername = async (accessToken: string, newUsername: string) => { + return authorizedRequest('/api/auth/username', accessToken, { + method: 'PATCH', + body: { newUsername }, + }); +}; + +export const updateEmail = async (accessToken: string, newEmail: string) => { + return authorizedRequest('/api/auth/email', accessToken, { + method: 'PATCH', + body: { newEmail }, + }); +}; + +export const updateProfile = async ( + accessToken: string, + firstName: string, + lastName: string, + dateOfBirth: string, +) => { + return authorizedRequest('/api/auth/profile', accessToken, { + method: 'PATCH', + body: { firstName, lastName, dateOfBirth }, + }); +}; + +export const updatePassword = async ( + accessToken: string, + currentPassword: string, + newPassword: string, +) => { + return authorizedRequest('/api/auth/password', accessToken, { + method: 'PATCH', + body: { currentPassword, newPassword }, + }); +}; + +export const deleteAccount = async (accessToken: string) => { + await authorizedRequest('/api/auth/account', accessToken, { method: 'DELETE' }); +}; + +export const createAuthSession = async (payload: LoginPayload, redirectTo: string) => { + const session = await sessionStorage.getSession(); + session.set('accessToken', payload.accessToken); + session.set('refreshToken', payload.refreshToken); + session.set('userAccountId', payload.userAccountId); + session.set('username', payload.username); + + return redirect(redirectTo, { + headers: { 'Set-Cookie': await commitSession(session) }, + }); +}; diff --git a/web/frontend/app/features/auth/components/ConfirmFailure.tsx b/web/frontend/app/features/auth/components/ConfirmFailure.tsx new file mode 100644 index 00000000..9e8ff6d6 --- /dev/null +++ b/web/frontend/app/features/auth/components/ConfirmFailure.tsx @@ -0,0 +1,25 @@ +import { Link } from 'react-router'; + +interface ConfirmFailureProps { + error: string; +} + +const ConfirmFailure = ({ error }: ConfirmFailureProps) => ( + <> +
✕
+

Confirmation Failed

+
+ {error} +
+

+ The confirmation link may have expired (valid for 30 minutes) or already been used. +

+
+ + Back to Dashboard + +
+ +); + +export default ConfirmFailure; diff --git a/web/frontend/app/features/auth/components/ConfirmSuccess.tsx b/web/frontend/app/features/auth/components/ConfirmSuccess.tsx new file mode 100644 index 00000000..7091b44f --- /dev/null +++ b/web/frontend/app/features/auth/components/ConfirmSuccess.tsx @@ -0,0 +1,26 @@ +import { Link } from 'react-router'; + +interface ConfirmSuccessProps { + confirmedDate: string; +} + +const ConfirmSuccess = ({ confirmedDate }: ConfirmSuccessProps) => ( + <> +
✓
+

Email Confirmed!

+

Your email address has been successfully verified.

+
+ + Confirmed at + +

{new Date(confirmedDate).toLocaleString()}

+
+
+ + Go to Dashboard + +
+ +); + +export default ConfirmSuccess; diff --git a/web/frontend/app/features/auth/components/LoginCallout.tsx b/web/frontend/app/features/auth/components/LoginCallout.tsx new file mode 100644 index 00000000..12022764 --- /dev/null +++ b/web/frontend/app/features/auth/components/LoginCallout.tsx @@ -0,0 +1,18 @@ +import { Link } from 'react-router'; + +const LoginCallout = () => ( + <> +
Already have an account?
+ +
+ + Sign in + + + Back to home + +
+ +); + +export default LoginCallout; diff --git a/web/frontend/app/features/auth/components/LoginForm.tsx b/web/frontend/app/features/auth/components/LoginForm.tsx new file mode 100644 index 00000000..399fdc07 --- /dev/null +++ b/web/frontend/app/features/auth/components/LoginForm.tsx @@ -0,0 +1,44 @@ +import { BaseSyntheticEvent } from 'react'; +import { FormState, UseFormRegister } from 'react-hook-form'; +import FormField from '../../../components/ui/forms/FormField'; +import SubmitButton from '../../../components/ui/forms/SubmitButton'; +import type { LoginSchema } from '../schemas'; + +interface LoginFormProps { + onSubmit: (e?: BaseSyntheticEvent) => Promise | undefined>; + formState: FormState; + register: UseFormRegister<{ username: string; password: string }>; + submitting: boolean; +} + +const LoginForm = (props: LoginFormProps) => ( +
+ + + + + + +); + +export default LoginForm; diff --git a/web/frontend/app/features/auth/components/RegisterCallout.tsx b/web/frontend/app/features/auth/components/RegisterCallout.tsx new file mode 100644 index 00000000..0f568057 --- /dev/null +++ b/web/frontend/app/features/auth/components/RegisterCallout.tsx @@ -0,0 +1,23 @@ +import { HomeSimpleDoor, UserPlus } from 'iconoir-react'; +import { Link } from 'react-router'; + +const RegisterCallout = () => ( + <> +
New here?
+
+ +
+ +); + +export default RegisterCallout; diff --git a/web/frontend/app/features/auth/components/RegisterForm.tsx b/web/frontend/app/features/auth/components/RegisterForm.tsx new file mode 100644 index 00000000..f118d164 --- /dev/null +++ b/web/frontend/app/features/auth/components/RegisterForm.tsx @@ -0,0 +1,97 @@ +import { BaseSyntheticEvent } from 'react'; +import { FormState, UseFormRegister } from 'react-hook-form'; +import FormField from '../../../components/ui/forms/FormField'; +import SubmitButton from '../../../components/ui/forms/SubmitButton'; +import type { RegisterSchema } from '../schemas'; + +interface RegisterFormProps { + onSubmit: (e?: BaseSyntheticEvent) => Promise | undefined>; + formState: FormState; + register: UseFormRegister; + submitting: boolean; +} + +const RegisterForm = (props: RegisterFormProps) => ( +
+ + +
+ + + +
+ + + + + + + + + + + +); + +export default RegisterForm; diff --git a/web/frontend/app/features/auth/hooks/useActionErrorToast.ts b/web/frontend/app/features/auth/hooks/useActionErrorToast.ts new file mode 100644 index 00000000..d7234b15 --- /dev/null +++ b/web/frontend/app/features/auth/hooks/useActionErrorToast.ts @@ -0,0 +1,8 @@ +import { useEffect } from 'react'; +import { showErrorToast } from '../../../components/ui/toast/toast'; + +export const useActionErrorToast = (error: string | undefined | null) => { + useEffect(() => { + if (error) showErrorToast(error); + }, [error]); +}; diff --git a/web/frontend/app/features/auth/routes/confirm.tsx b/web/frontend/app/features/auth/routes/confirm.tsx new file mode 100644 index 00000000..345b45fe --- /dev/null +++ b/web/frontend/app/features/auth/routes/confirm.tsx @@ -0,0 +1,49 @@ +import { confirmEmail, requireAuth } from '../auth.server'; +import ConfirmFailure from '../components/ConfirmFailure'; +import ConfirmSuccess from '../components/ConfirmSuccess'; +import type { Route } from './+types/confirm'; + +export const meta = ({}: Route.MetaArgs) => { + return [{ title: 'Confirm Email | The Biergarten App' }]; +}; + +export const loader = async ({ request }: Route.LoaderArgs) => { + const auth = await requireAuth(request); + const url = new URL(request.url); + const token = url.searchParams.get('token'); + + if (!token) { + return { success: false as const, error: 'Missing confirmation token.' }; + } + + try { + const payload = await confirmEmail(token, auth.accessToken); + return { + success: true as const, + confirmedDate: payload.confirmedDate, + }; + } catch (err) { + return { + success: false as const, + error: err instanceof Error ? err.message : 'Confirmation failed.', + }; + } +}; + +const Confirm = ({ loaderData }: Route.ComponentProps) => { + return ( +
+
+
+ {loaderData.success ? ( + + ) : ( + + )} +
+
+
+ ); +}; + +export default Confirm; diff --git a/web/frontend/app/features/auth/routes/login.tsx b/web/frontend/app/features/auth/routes/login.tsx new file mode 100644 index 00000000..5178e7af --- /dev/null +++ b/web/frontend/app/features/auth/routes/login.tsx @@ -0,0 +1,88 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { LogIn } from 'iconoir-react'; +import { useForm } from 'react-hook-form'; +import { redirect, useNavigation, useSubmit } from 'react-router'; +import { createAuthSession, getOptionalAuth, login } from '../auth.server'; +import LoginForm from '../components/LoginForm'; +import RegisterCallout from '../components/RegisterCallout'; +import { useActionErrorToast } from '../hooks/useActionErrorToast'; +import { loginSchema, type LoginSchema } from '../schemas'; +import type { Route } from './+types/login'; + +export const meta = ({}: Route.MetaArgs) => { + return [{ title: 'Login | The Biergarten App' }]; +}; + +export const loader = async ({ request }: Route.LoaderArgs) => { + const auth = await getOptionalAuth(request); + if (auth) throw redirect('/dashboard'); + return null; +}; + +export const action = async ({ request }: Route.ActionArgs) => { + const formData = await request.formData(); + const result = loginSchema.safeParse({ + username: formData.get('username'), + password: formData.get('password'), + }); + + if (!result.success) { + return { error: result.error.issues[0].message }; + } + + try { + const payload = await login(result.data.username, result.data.password); + return createAuthSession(payload, '/dashboard'); + } catch (err) { + return { error: err instanceof Error ? err.message : 'Login failed.' }; + } +}; + +const Login = ({ actionData }: Route.ComponentProps) => { + const navigation = useNavigation(); + const submit = useSubmit(); + const isSubmitting = navigation.state === 'submitting'; + + const { register, handleSubmit, formState } = useForm({ + resolver: zodResolver(loginSchema), + }); + + const onSubmit = handleSubmit((data) => { + submit(data, { method: 'post' }); + }); + + useActionErrorToast(actionData?.error); + + return ( +
+
+
+
+

+

+

Sign in to your Biergarten account

+
+ + {actionData?.error && ( +
+ {actionData.error} +
+ )} + + + + +
+
+
+ ); +}; + +export default Login; diff --git a/web/frontend/app/features/auth/routes/logout.tsx b/web/frontend/app/features/auth/routes/logout.tsx new file mode 100644 index 00000000..0896f7be --- /dev/null +++ b/web/frontend/app/features/auth/routes/logout.tsx @@ -0,0 +1,10 @@ +import { redirect } from 'react-router'; +import { destroySession, getSession } from '../auth.server'; +import type { Route } from './+types/logout'; + +export const loader = async ({ request }: Route.LoaderArgs) => { + const session = await getSession(request); + return redirect('/', { + headers: { 'Set-Cookie': await destroySession(session) }, + }); +}; diff --git a/web/frontend/app/features/auth/routes/register.tsx b/web/frontend/app/features/auth/routes/register.tsx new file mode 100644 index 00000000..1d14db2a --- /dev/null +++ b/web/frontend/app/features/auth/routes/register.tsx @@ -0,0 +1,106 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { redirect, useNavigation, useSubmit } from 'react-router'; +import { createAuthSession, getOptionalAuth, register } from '../auth.server'; +import LoginCallout from '../components/LoginCallout'; +import RegisterForm from '../components/RegisterForm'; +import { useActionErrorToast } from '../hooks/useActionErrorToast'; +import { registerSchema, type RegisterSchema } from '../schemas'; +import type { Route } from './+types/register'; + +export const meta = ({}: Route.MetaArgs) => { + return [{ title: 'Register | The Biergarten App' }]; +}; + +export const loader = async ({ request }: Route.LoaderArgs) => { + const auth = await getOptionalAuth(request); + if (auth) throw redirect('/dashboard'); + return null; +}; + +export const action = async ({ request }: Route.ActionArgs) => { + const formData = await request.formData(); + const result = registerSchema.safeParse({ + username: formData.get('username'), + firstName: formData.get('firstName'), + lastName: formData.get('lastName'), + email: formData.get('email'), + dateOfBirth: formData.get('dateOfBirth'), + password: formData.get('password'), + confirmPassword: formData.get('confirmPassword'), + }); + + if (!result.success) { + const fieldErrors = result.error.flatten().fieldErrors as Record< + keyof RegisterSchema, + string[] | undefined + >; + return { error: null, fieldErrors }; + } + + try { + const body = { + username: result.data.username, + firstName: result.data.firstName, + lastName: result.data.lastName, + email: result.data.email, + dateOfBirth: result.data.dateOfBirth, + password: result.data.password, + }; + const payload = await register(body); + return createAuthSession(payload, '/dashboard'); + } catch (err) { + return { + error: err instanceof Error ? err.message : 'Registration failed.', + fieldErrors: null, + }; + } +}; + +const Register = ({ actionData }: Route.ComponentProps) => { + const navigation = useNavigation(); + const submit = useSubmit(); + const isSubmitting = navigation.state === 'submitting'; + + const { + register: field, + handleSubmit, + formState, + } = useForm({ resolver: zodResolver(registerSchema) }); + + const onSubmit = handleSubmit((data) => { + submit(data, { method: 'post' }); + }); + + useActionErrorToast(actionData?.error); + + return ( +
+
+
+
+

Register

+

Create your Biergarten account

+
+ + {actionData?.error && ( +
+ {actionData.error} +
+ )} + + + + +
+
+
+ ); +}; + +export default Register; diff --git a/web/frontend/app/features/auth/schemas.ts b/web/frontend/app/features/auth/schemas.ts new file mode 100644 index 00000000..b2fdfeb2 --- /dev/null +++ b/web/frontend/app/features/auth/schemas.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +export const loginSchema = z.object({ + username: z.string().min(1, 'Username is required'), + password: z.string().min(1, 'Password is required'), +}); + +export type LoginSchema = z.infer; + +export const registerSchema = z + .object({ + username: z + .string() + .min(3, 'Username must be at least 3 characters') + .max(20, 'Username must be at most 20 characters'), + firstName: z.string().min(1, 'First name is required'), + lastName: z.string().min(1, 'Last name is required'), + email: z.string().email('Invalid email address'), + dateOfBirth: z.string().min(1, 'Date of birth is required'), + password: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex(/[A-Z]/, 'Password must contain an uppercase letter') + .regex(/[a-z]/, 'Password must contain a lowercase letter') + .regex(/[0-9]/, 'Password must contain a number'), + confirmPassword: z.string().min(1, 'Please confirm your password'), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords must match', + path: ['confirmPassword'], + }); + +export type RegisterSchema = z.infer; diff --git a/web/frontend/app/features/breweries/breweries.server.ts b/web/frontend/app/features/breweries/breweries.server.ts new file mode 100644 index 00000000..201d98fd --- /dev/null +++ b/web/frontend/app/features/breweries/breweries.server.ts @@ -0,0 +1,55 @@ +const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080'; + +interface ApiResponse { + message: string; + payload: T; +} + +export interface BreweryLocation { + breweryPostLocationId: string; + breweryPostId: string; + cityId: string; + addressLine1: string; + addressLine2: string | null; + postalCode: string; +} + +export interface Brewery { + breweryPostId: string; + postedById: string; + breweryName: string; + description: string; + createdAt: string; + updatedAt: string | null; + location: BreweryLocation | null; +} + +export const getBreweries = async (limit?: number, offset?: number): Promise => { + const params = new URLSearchParams(); + if (limit !== undefined) params.set('limit', String(limit)); + if (offset !== undefined) params.set('offset', String(offset)); + + const res = await fetch(`${API_BASE_URL}/api/brewery?${params}`); + + if (!res.ok) { + throw new Error(`Failed to load breweries (${res.status})`); + } + + const data: ApiResponse = await res.json(); + return data.payload; +}; + +export const getBreweryById = async (id: string): Promise => { + const res = await fetch(`${API_BASE_URL}/api/brewery/${encodeURIComponent(id)}`); + + if (res.status === 404) { + return null; + } + + if (!res.ok) { + throw new Error(`Failed to load brewery (${res.status})`); + } + + const data: ApiResponse = await res.json(); + return data.payload; +}; diff --git a/web/frontend/app/features/breweries/components/BreweryCard.tsx b/web/frontend/app/features/breweries/components/BreweryCard.tsx new file mode 100644 index 00000000..b1a1a96f --- /dev/null +++ b/web/frontend/app/features/breweries/components/BreweryCard.tsx @@ -0,0 +1,26 @@ +import { Link } from 'react-router'; +import type { Brewery } from '../breweries.server'; + +interface BreweryCardProps { + brewery: Brewery; +} + +const BreweryCard = ({ brewery }: BreweryCardProps) => ( + +
+

{brewery.breweryName}

+

{brewery.description}

+ {brewery.location && ( +

+ {brewery.location.addressLine1} + {brewery.location.addressLine2 ? `, ${brewery.location.addressLine2}` : ''} +

+ )} +
+ +); + +export default BreweryCard; diff --git a/web/frontend/app/features/breweries/routes/breweries.tsx b/web/frontend/app/features/breweries/routes/breweries.tsx new file mode 100644 index 00000000..987645da --- /dev/null +++ b/web/frontend/app/features/breweries/routes/breweries.tsx @@ -0,0 +1,39 @@ +import BreweryCard from '../components/BreweryCard'; +import { getBreweries } from '../breweries.server'; +import type { Route } from './+types/breweries'; + +export const meta = ({}: Route.MetaArgs) => { + return [{ title: 'Breweries | The Biergarten App' }]; +}; + +export const loader = async () => { + const breweries = await getBreweries(); + return { breweries }; +}; + +const Breweries = ({ loaderData }: Route.ComponentProps) => { + const { breweries } = loaderData; + + return ( +
+
+

Breweries

+

Discover our partner breweries.

+ + {breweries.length === 0 ? ( +
+ No breweries have been posted yet. +
+ ) : ( +
+ {breweries.map((brewery) => ( + + ))} +
+ )} +
+
+ ); +}; + +export default Breweries; diff --git a/web/frontend/app/features/breweries/routes/brewery-detail.tsx b/web/frontend/app/features/breweries/routes/brewery-detail.tsx new file mode 100644 index 00000000..45de9ba0 --- /dev/null +++ b/web/frontend/app/features/breweries/routes/brewery-detail.tsx @@ -0,0 +1,59 @@ +import { data, Link } from 'react-router'; +import { getBreweryById } from '../breweries.server'; +import type { Route } from './+types/brewery-detail'; + +export const loader = async ({ params }: Route.LoaderArgs) => { + const brewery = await getBreweryById(params.id); + if (!brewery) { + throw data('Brewery not found.', { status: 404 }); + } + + return { brewery }; +}; + +const BreweryDetail = ({ loaderData }: Route.ComponentProps) => { + const { brewery } = loaderData; + + return ( +
+
+ + ← Back to Breweries + + +
+
+

{brewery.breweryName}

+

+ {brewery.description} +

+ + {brewery.location && ( +
+

+ Location +

+

{brewery.location.addressLine1}

+ {brewery.location.addressLine2 && ( +

{brewery.location.addressLine2}

+ )} +

{brewery.location.postalCode}

+
+ )} + +

+ Posted {new Date(brewery.createdAt).toLocaleDateString()} + {brewery.updatedAt && + ` · Updated ${new Date(brewery.updatedAt).toLocaleDateString()}`} +

+
+
+
+
+ ); +}; + +export default BreweryDetail; diff --git a/web/frontend/app/features/catalog/routes/beer-styles.tsx b/web/frontend/app/features/catalog/routes/beer-styles.tsx new file mode 100644 index 00000000..1fbf7761 --- /dev/null +++ b/web/frontend/app/features/catalog/routes/beer-styles.tsx @@ -0,0 +1,18 @@ +import type { Route } from './+types/beer-styles'; + +export const meta = ({}: Route.MetaArgs) => { + return [{ title: 'Beer Styles | The Biergarten App' }]; +}; + +const BeerStyles = () => { + return ( +
+
+

Beer Styles

+

Learn about different beer styles.

+
+
+ ); +}; + +export default BeerStyles; diff --git a/web/frontend/app/features/catalog/routes/beers.tsx b/web/frontend/app/features/catalog/routes/beers.tsx new file mode 100644 index 00000000..1712a76d --- /dev/null +++ b/web/frontend/app/features/catalog/routes/beers.tsx @@ -0,0 +1,18 @@ +import type { Route } from './+types/beers'; + +export const meta = ({}: Route.MetaArgs) => { + return [{ title: 'Beers | The Biergarten App' }]; +}; + +const Beers = () => { + return ( +
+
+

Beers

+

Explore our collection of beers.

+
+
+ ); +}; + +export default Beers; diff --git a/web/frontend/app/features/home/routes/home.tsx b/web/frontend/app/features/home/routes/home.tsx new file mode 100644 index 00000000..f793c9a3 --- /dev/null +++ b/web/frontend/app/features/home/routes/home.tsx @@ -0,0 +1,56 @@ +import { Link } from 'react-router'; +import { getOptionalAuth } from '../../auth/auth.server'; +import type { Route } from './+types/home'; + +export const meta = ({}: Route.MetaArgs) => [ + { title: 'The Biergarten App' }, + { name: 'description', content: 'Welcome to The Biergarten App' }, +]; + +export const loader = async ({ request }: Route.LoaderArgs) => { + const auth = await getOptionalAuth(request); + return { username: auth?.username ?? null }; +}; + +const Home = ({ loaderData }: Route.ComponentProps) => { + const { username } = loaderData; + + return ( +
+
+
+

🍺 The Biergarten App

+

Authentication Demo

+ + {username ? ( + <> +

+ Welcome back,{' '} + {username}! +

+
+ + Dashboard + + + Logout + +
+ + ) : ( +
+ + Login + + + Register + +
+ )} +
+
+
+ ); +}; + +export default Home; diff --git a/web/frontend/app/features/theme/components/ThemeSwitcher.tsx b/web/frontend/app/features/theme/components/ThemeSwitcher.tsx new file mode 100644 index 00000000..60c9f5bc --- /dev/null +++ b/web/frontend/app/features/theme/components/ThemeSwitcher.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { + biergartenThemes, + defaultThemeName, + isBiergartenTheme, + type ThemeName, + themeStorageKey, +} from '../themes'; + +const applyTheme = (theme: ThemeName) => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(themeStorageKey, theme); +}; + +const ThemeSwitcher = () => { + const [selectedTheme, setSelectedTheme] = useState(() => { + if (typeof window === 'undefined') { + return defaultThemeName; + } + + const savedTheme = localStorage.getItem(themeStorageKey); + return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName; + }); + + const activeTheme = + biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0]; + + return ( + <> +
+
+

Theme Guide

+

+ Four themes, four moods — from the sun-bleached clarity of a Weizen + afternoon to the deep berry dark of a Cassis barrel. Every theme shares the + same semantic token structure so components stay consistent while the + atmosphere shifts completely. +

+
+ + Active theme: {activeTheme.label} — {activeTheme.vibe} + +
+
+
+ +
+
+

Theme switcher

+

Pick a theme and preview it immediately.

+ +
+ {biergartenThemes.map((theme) => { + const checked = selectedTheme === theme.value; + + return ( + + ); + })} +
+
+
+ + ); +}; + +export default ThemeSwitcher; diff --git a/web/frontend/app/features/theme/routes/theme.tsx b/web/frontend/app/features/theme/routes/theme.tsx new file mode 100644 index 00000000..fcd2595b --- /dev/null +++ b/web/frontend/app/features/theme/routes/theme.tsx @@ -0,0 +1,99 @@ +import ThemeSwitcher from '../components/ThemeSwitcher'; +import type { Route } from './+types/theme'; + +export const meta = ({}: Route.MetaArgs) => { + return [ + { title: 'Theme | The Biergarten App' }, + { + name: 'description', + content: 'Theme guide and switcher for The Biergarten App', + }, + ]; +}; + +const ThemePage = () => { + return ( +
+
+ + +
+
+
+

Brand colors

+
+
+ Primary +
+
+ Secondary +
+
+ Accent +
+
+ Neutral +
+
+
+
+ +
+
+

Status colors

+
+
+ Info +
+
+ Success +
+
+ Warning +
+
+ Error +
+
+
+
+ +
+
+

Core style outline

+
    +
  • Warm serif headings paired with clear sans-serif body text
  • +
  • Rounded, tactile surfaces with subtle depth and grain
  • +
  • + Semantic token usage to keep contrast consistent in both themes +
  • +
+
+
+
+ +
+
+

Component preview

+
+ + + + +
+
+
+ Theme tokens are applied consistently. +
+
+ Use semantic colors over hard-coded color values. +
+
+
+
+
+
+ ); +}; + +export default ThemePage; diff --git a/web/frontend/app/features/theme/themes.ts b/web/frontend/app/features/theme/themes.ts new file mode 100644 index 00000000..14bc9f1e --- /dev/null +++ b/web/frontend/app/features/theme/themes.ts @@ -0,0 +1,38 @@ +export type ThemeName = + 'biergarten-lager' | 'biergarten-stout' | 'biergarten-cassis' | 'biergarten-weizen'; + +export interface ThemeOption { + value: ThemeName; + label: string; + vibe: string; +} + +export const defaultThemeName: ThemeName = 'biergarten-lager'; +export const themeStorageKey = 'biergarten-theme'; + +export const biergartenThemes: ThemeOption[] = [ + { + value: 'biergarten-lager', + label: 'Biergarten Lager', + vibe: 'Muted parchment, mellow amber, daytime beer garden', + }, + { + value: 'biergarten-stout', + label: 'Biergarten Stout', + vibe: 'Charred barrel, deep roast, cozy evening cellar', + }, + { + value: 'biergarten-cassis', + label: 'Biergarten Cassis', + vibe: 'Blackberry barrel, sour berry dark, vivid night market', + }, + { + value: 'biergarten-weizen', + label: 'Biergarten Weizen', + vibe: 'Ultra-light young barley, green undertone, bright spring afternoon', + }, +]; + +export const isBiergartenTheme = (value: string | null | undefined): value is ThemeName => { + return biergartenThemes.some((theme) => theme.value === value); +}; diff --git a/web/frontend/app/lib/auth.server.ts b/web/frontend/app/lib/auth.server.ts deleted file mode 100644 index 760ce148..00000000 --- a/web/frontend/app/lib/auth.server.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { createCookieSessionStorage, redirect } from 'react-router'; - -const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080'; - -export interface AuthTokens { - accessToken: string; - refreshToken: string; - userAccountId: string; - username: string; -} - -interface ApiResponse { - message: string; - payload: T; -} - -interface LoginPayload { - userAccountId: string; - username: string; - refreshToken: string; - accessToken: string; -} - -interface RegistrationPayload extends LoginPayload { - confirmationEmailSent: boolean; -} - -const sessionStorage = createCookieSessionStorage({ - cookie: { - name: '__session', - httpOnly: true, - maxAge: 60 * 60 * 24 * 21, // 21 days (matches refresh token) - path: '/', - sameSite: 'lax', - secrets: [process.env.SESSION_SECRET || 'dev-secret-change-me'], - secure: process.env.NODE_ENV === 'production', - }, -}); - -export async function getSession(request: Request) { - return sessionStorage.getSession(request.headers.get('Cookie')); -} - -export async function commitSession(session: Awaited>) { - return sessionStorage.commitSession(session); -} - -export async function destroySession(session: Awaited>) { - return sessionStorage.destroySession(session); -} - -export async function requireAuth(request: Request): Promise { - const session = await getSession(request); - const accessToken = session.get('accessToken'); - const refreshToken = session.get('refreshToken'); - - if (!accessToken || !refreshToken) { - throw redirect('/login'); - } - - return { - accessToken, - refreshToken, - userAccountId: session.get('userAccountId'), - username: session.get('username'), - }; -} - -export async function getOptionalAuth(request: Request): Promise { - const session = await getSession(request); - const accessToken = session.get('accessToken'); - - if (!accessToken) return null; - - return { - accessToken, - refreshToken: session.get('refreshToken'), - userAccountId: session.get('userAccountId'), - username: session.get('username'), - }; -} - -export async function login(username: string, password: string) { - const res = await fetch(`${API_BASE_URL}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `Login failed (${res.status})`); - } - - const data: ApiResponse = await res.json(); - return data.payload; -} - -export async function register(body: { - username: string; - firstName: string; - lastName: string; - email: string; - dateOfBirth: string; - password: string; -}) { - const res = await fetch(`${API_BASE_URL}/api/auth/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `Registration failed (${res.status})`); - } - - const data: ApiResponse = await res.json(); - return data.payload; -} - -export async function refreshTokens(refreshToken: string) { - const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refreshToken }), - }); - - if (!res.ok) { - throw new Error('Token refresh failed'); - } - - const data: ApiResponse = await res.json(); - return data.payload; -} - -export async function confirmEmail(token: string, accessToken: string) { - const res = await fetch(`${API_BASE_URL}/api/auth/confirm?token=${encodeURIComponent(token)}`, { - method: 'POST', - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `Confirmation failed (${res.status})`); - } - - const data: ApiResponse<{ userAccountId: string; confirmedDate: string }> = await res.json(); - return data.payload; -} - -export async function createAuthSession(payload: LoginPayload, redirectTo: string) { - const session = await sessionStorage.getSession(); - session.set('accessToken', payload.accessToken); - session.set('refreshToken', payload.refreshToken); - session.set('userAccountId', payload.userAccountId); - session.set('username', payload.username); - - return redirect(redirectTo, { - headers: { 'Set-Cookie': await commitSession(session) }, - }); -} diff --git a/web/frontend/app/lib/breweries.server.ts b/web/frontend/app/lib/breweries.server.ts deleted file mode 100644 index e83b46fa..00000000 --- a/web/frontend/app/lib/breweries.server.ts +++ /dev/null @@ -1,55 +0,0 @@ -const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080'; - -interface ApiResponse { - message: string; - payload: T; -} - -export interface BreweryLocation { - breweryPostLocationId: string; - breweryPostId: string; - cityId: string; - addressLine1: string; - addressLine2: string | null; - postalCode: string; -} - -export interface Brewery { - breweryPostId: string; - postedById: string; - breweryName: string; - description: string; - createdAt: string; - updatedAt: string | null; - location: BreweryLocation | null; -} - -export async function getBreweries(limit?: number, offset?: number): Promise { - const params = new URLSearchParams(); - if (limit !== undefined) params.set('limit', String(limit)); - if (offset !== undefined) params.set('offset', String(offset)); - - const res = await fetch(`${API_BASE_URL}/api/brewery?${params}`); - - if (!res.ok) { - throw new Error(`Failed to load breweries (${res.status})`); - } - - const data: ApiResponse = await res.json(); - return data.payload; -} - -export async function getBreweryById(id: string): Promise { - const res = await fetch(`${API_BASE_URL}/api/brewery/${encodeURIComponent(id)}`); - - if (res.status === 404) { - return null; - } - - if (!res.ok) { - throw new Error(`Failed to load brewery (${res.status})`); - } - - const data: ApiResponse = await res.json(); - return data.payload; -} diff --git a/web/frontend/app/lib/schemas.ts b/web/frontend/app/lib/schemas.ts deleted file mode 100644 index 90b83da8..00000000 --- a/web/frontend/app/lib/schemas.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { z } from 'zod'; - -export const loginSchema = z.object({ - username: z.string().min(1, 'Username is required'), - password: z.string().min(1, 'Password is required'), -}); - -export type LoginSchema = z.infer; - -export const registerSchema = z - .object({ - username: z - .string() - .min(3, 'Username must be at least 3 characters') - .max(20, 'Username must be at most 20 characters'), - firstName: z.string().min(1, 'First name is required'), - lastName: z.string().min(1, 'Last name is required'), - email: z.string().email('Invalid email address'), - dateOfBirth: z.string().min(1, 'Date of birth is required'), - password: z - .string() - .min(8, 'Password must be at least 8 characters') - .regex(/[A-Z]/, 'Password must contain an uppercase letter') - .regex(/[a-z]/, 'Password must contain a lowercase letter') - .regex(/[0-9]/, 'Password must contain a number'), - confirmPassword: z.string().min(1, 'Please confirm your password'), - }) - .refine((data) => data.password === data.confirmPassword, { - message: 'Passwords must match', - path: ['confirmPassword'], - }); - -export type RegisterSchema = z.infer; diff --git a/web/frontend/app/lib/themes.ts b/web/frontend/app/lib/themes.ts deleted file mode 100644 index bc773b0c..00000000 --- a/web/frontend/app/lib/themes.ts +++ /dev/null @@ -1,41 +0,0 @@ -export type ThemeName = - | 'biergarten-lager' - | 'biergarten-stout' - | 'biergarten-cassis' - | 'biergarten-weizen'; - -export interface ThemeOption { - value: ThemeName; - label: string; - vibe: string; -} - -export const defaultThemeName: ThemeName = 'biergarten-lager'; -export const themeStorageKey = 'biergarten-theme'; - -export const biergartenThemes: ThemeOption[] = [ - { - value: 'biergarten-lager', - label: 'Biergarten Lager', - vibe: 'Muted parchment, mellow amber, daytime beer garden', - }, - { - value: 'biergarten-stout', - label: 'Biergarten Stout', - vibe: 'Charred barrel, deep roast, cozy evening cellar', - }, - { - value: 'biergarten-cassis', - label: 'Biergarten Cassis', - vibe: 'Blackberry barrel, sour berry dark, vivid night market', - }, - { - value: 'biergarten-weizen', - label: 'Biergarten Weizen', - vibe: 'Ultra-light young barley, green undertone, bright spring afternoon', - }, -]; - -export function isBiergartenTheme(value: string | null | undefined): value is ThemeName { - return biergartenThemes.some((theme) => theme.value === value); -} diff --git a/web/frontend/app/root.tsx b/web/frontend/app/root.tsx index dac0d97e..ee2937d8 100644 --- a/web/frontend/app/root.tsx +++ b/web/frontend/app/root.tsx @@ -1,90 +1,103 @@ import { - isRouteErrorResponse, - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, + isRouteErrorResponse, + Links, + Meta, + Outlet, + Scripts, + ScrollRestoration, } from 'react-router'; import type { Route } from './+types/root'; import './app.css'; -import Navbar from './components/Navbar'; -import ToastProvider from './components/toast/ToastProvider'; -import { getOptionalAuth } from './lib/auth.server'; +import Navbar from './components/ui/navbar/Navbar'; +import ToastProvider from './components/ui/toast/ToastProvider'; +import { getOptionalAuth } from './features/auth/auth.server'; +import { themeStorageKey } from './features/theme/themes'; + +const themeInitScript = ` + (function () { + try { + var theme = localStorage.getItem(${JSON.stringify(themeStorageKey)}); + if (theme) document.documentElement.setAttribute('data-theme', theme); + } catch (e) {} + })(); +`; export const links: Route.LinksFunction = () => [ - { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, - { - rel: 'preconnect', - href: 'https://fonts.gstatic.com', - crossOrigin: 'anonymous', - }, - { - rel: 'stylesheet', - href: 'https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..900;1,9..40,100..900&family=Volkhov:ital,wght@0,400;0,700;1,400;1,700&display=swap', - }, + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossOrigin: 'anonymous', + }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..900;1,9..40,100..900&family=Volkhov:ital,wght@0,400;0,700;1,400;1,700&display=swap', + }, ]; export const loader = async ({ request }: Route.LoaderArgs) => { - const auth = await getOptionalAuth(request); - return { auth }; + const auth = await getOptionalAuth(request); + return { auth }; }; -export function Layout({ children }: { children: React.ReactNode }) { - return ( - - - - - - - - - {children} - - - - - ); -} +export const Layout = ({ children }: { children: React.ReactNode }) => { + return ( + + + + + + +