mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-04-05 18:09:04 +00:00
Compare commits
2 Commits
60b784e365
...
main-2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd3c172e35 | ||
|
|
581863d69b |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -15,6 +15,14 @@
|
|||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
# project-specific build artifacts
|
||||||
|
/src/Website/build/
|
||||||
|
/src/Website/storybook-static/
|
||||||
|
/src/Website/.react-router/
|
||||||
|
/src/Website/playwright-report/
|
||||||
|
/src/Website/test-results/
|
||||||
|
/test-results/
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
@@ -42,6 +50,9 @@ next-env.d.ts
|
|||||||
|
|
||||||
# vscode
|
# vscode
|
||||||
.vscode
|
.vscode
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
/cloudinary-images
|
/cloudinary-images
|
||||||
|
|
||||||
@@ -487,3 +498,6 @@ FodyWeavers.xsd
|
|||||||
.env.dev
|
.env.dev
|
||||||
.env.test
|
.env.test
|
||||||
.env.prod
|
.env.prod
|
||||||
|
|
||||||
|
*storybook.log
|
||||||
|
storybook-static
|
||||||
|
|||||||
285
README.md
285
README.md
@@ -1,261 +1,142 @@
|
|||||||
# The Biergarten App
|
# The Biergarten App
|
||||||
|
|
||||||
A social platform for craft beer enthusiasts to discover breweries, share reviews, and
|
The Biergarten App is a multi-project monorepo with a .NET backend and an active React
|
||||||
connect with fellow beer lovers.
|
Router frontend in `src/Website`. The current website focuses on account flows, theme
|
||||||
|
switching, shared UI components, Storybook coverage, and integration with the API.
|
||||||
|
|
||||||
**Documentation**
|
## Documentation
|
||||||
|
|
||||||
- [Getting Started](docs/getting-started.md) - Setup and installation
|
- [Getting Started](docs/getting-started.md) - Local setup for backend and active website
|
||||||
- [Architecture](docs/architecture.md) - System design and patterns
|
- [Architecture](docs/architecture.md) - Current backend and frontend architecture
|
||||||
- [Database](docs/database.md) - Schema and stored procedures
|
- [Docker Guide](docs/docker.md) - Container-based backend development and testing
|
||||||
- [Docker Guide](docs/docker.md) - Container deployment
|
- [Testing](docs/testing.md) - Backend and frontend test commands
|
||||||
- [Testing](docs/testing.md) - Test strategy and commands
|
- [Environment Variables](docs/environment-variables.md) - Active configuration reference
|
||||||
- [Environment Variables](docs/environment-variables.md) - Configuration reference
|
- [Token Validation](docs/token-validation.md) - JWT validation architecture
|
||||||
|
- [Legacy Website Archive](docs/archive/legacy-website-v1.md) - Archived notes for the old Next.js frontend
|
||||||
|
|
||||||
**Diagrams**
|
## Diagrams
|
||||||
|
|
||||||
- [Architecture](docs/diagrams/pdf/architecture.pdf) - Layered architecture
|
- [Architecture](docs/diagrams-out/architecture.svg) - Layered architecture
|
||||||
- [Deployment](docs/diagrams/pdf/deployment.pdf) - Docker topology
|
- [Deployment](docs/diagrams-out/deployment.svg) - Docker topology
|
||||||
- [Authentication Flow](docs/diagrams/pdf/authentication-flow.pdf) - Auth sequence
|
- [Authentication Flow](docs/diagrams-out/authentication-flow.svg) - Auth sequence
|
||||||
- [Database Schema](docs/diagrams/pdf/database-schema.pdf) - Entity relationships
|
- [Database Schema](docs/diagrams-out/database-schema.svg) - Entity relationships
|
||||||
|
|
||||||
## Project Status
|
## Current Status
|
||||||
|
|
||||||
**Active Development** - Transitioning from full-stack Next.js to multi-project monorepo
|
Active areas in the repository:
|
||||||
|
|
||||||
- Core authentication and user management APIs
|
- .NET 10 backend with layered architecture and SQL Server
|
||||||
- Database schema with migrations and seeding
|
- React Router 7 website in `src/Website`
|
||||||
- Layered architecture (Domain, Service, Infrastructure, Repository, API)
|
- Shared Biergarten theme system with a theme guide route
|
||||||
- Comprehensive test suite (unit + integration)
|
- Storybook stories and browser-based checks for shared UI
|
||||||
- Frontend integration with .NET API (in progress)
|
- Auth demo flows for home, login, register, dashboard, logout, and confirmation
|
||||||
- Migration from Next.js serverless functions
|
- Toast-based feedback for auth outcomes
|
||||||
|
|
||||||
---
|
Legacy area retained for reference:
|
||||||
|
|
||||||
|
- `src/Website-v1` contains the archived Next.js frontend and is no longer the active website
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
**Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp **Frontend**: Next.js 14+,
|
- **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp
|
||||||
TypeScript, TailwindCSS **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
- **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5
|
||||||
**Infrastructure**: Docker, Docker Compose **Security**: Argon2id password hashing, JWT
|
- **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
|
||||||
(HS256)
|
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
||||||
|
- **Infrastructure**: Docker, Docker Compose
|
||||||
---
|
- **Security**: Argon2id password hashing, JWT access/refresh/confirmation tokens
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Backend
|
||||||
|
|
||||||
- [.NET SDK 10+](https://dotnet.microsoft.com/download)
|
|
||||||
- [Docker Desktop](https://www.docker.com/products/docker-desktop)
|
|
||||||
- [Node.js 18+](https://nodejs.org/) (for frontend)
|
|
||||||
|
|
||||||
### Start Development Environment
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone repository
|
|
||||||
git clone https://github.com/aaronpo97/the-biergarten-app
|
git clone https://github.com/aaronpo97/the-biergarten-app
|
||||||
cd the-biergarten-app
|
cd the-biergarten-app
|
||||||
|
|
||||||
# Configure environment
|
|
||||||
cp .env.example .env.dev
|
cp .env.example .env.dev
|
||||||
|
|
||||||
# Start all services
|
|
||||||
docker compose -f docker-compose.dev.yaml up -d
|
docker compose -f docker-compose.dev.yaml up -d
|
||||||
|
|
||||||
# View logs
|
|
||||||
docker compose -f docker-compose.dev.yaml logs -f
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Access**:
|
Backend access:
|
||||||
|
|
||||||
- API: http://localhost:8080/swagger
|
- API Swagger: http://localhost:8080/swagger
|
||||||
- Health: http://localhost:8080/health
|
- Health Check: http://localhost:8080/health
|
||||||
|
|
||||||
### Run Tests
|
### Frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
cd src/Website
|
||||||
|
npm install
|
||||||
|
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Results are in `./test-results/`
|
Optional frontend tools:
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
cd src/Website
|
||||||
|
npm run storybook
|
||||||
|
npm run test:storybook
|
||||||
|
npm run test:storybook:playwright
|
||||||
|
```
|
||||||
|
|
||||||
## Repository Structure
|
## Repository Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/Core/ Backend projects (.NET)
|
||||||
|
src/Website/ Active React Router frontend
|
||||||
|
src/Website-v1/ Archived legacy Next.js frontend
|
||||||
|
docs/ Active project documentation
|
||||||
|
docs/archive/ Archived legacy documentation
|
||||||
```
|
```
|
||||||
src/Core/ # Backend (.NET)
|
|
||||||
├── API/
|
|
||||||
│ ├── API.Core/ # ASP.NET Core Web API
|
|
||||||
│ └── API.Specs/ # Integration tests (Reqnroll)
|
|
||||||
├── Database/
|
|
||||||
│ ├── Database.Migrations/ # DbUp migrations
|
|
||||||
│ └── Database.Seed/ # Data seeding
|
|
||||||
├── Domain.Entities/ # Domain models
|
|
||||||
├── Infrastructure/ # Cross-cutting concerns
|
|
||||||
│ ├── Infrastructure.Jwt/
|
|
||||||
│ ├── Infrastructure.PasswordHashing/
|
|
||||||
│ ├── Infrastructure.Email/
|
|
||||||
│ ├── Infrastructure.Repository/
|
|
||||||
│ └── Infrastructure.Repository.Tests/
|
|
||||||
└── Service/ # Business logic
|
|
||||||
├── Service.Auth/
|
|
||||||
├── Service.Auth.Tests/
|
|
||||||
└── Service.UserManagement/
|
|
||||||
|
|
||||||
Website/ # Frontend (Next.js)
|
|
||||||
docs/ # Documentation
|
|
||||||
docs/diagrams/ # PlantUML diagrams
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
### Implemented
|
Implemented today:
|
||||||
|
|
||||||
- User registration and authentication
|
- User registration and login against the API
|
||||||
- JWT token-based auth
|
- JWT-based auth with access, refresh, and confirmation flows
|
||||||
- Argon2id password hashing
|
- SQL Server migrations and seed projects
|
||||||
- SQL Server with stored procedures
|
- Shared form components and auth screens
|
||||||
- Database migrations (DbUp)
|
- Theme switching with Lager, Stout, Cassis, and Weizen variants
|
||||||
- Docker containerization
|
- Storybook documentation and automated story interaction tests
|
||||||
- Comprehensive test suite
|
- Toast feedback for auth-related outcomes
|
||||||
- Swagger/OpenAPI documentation
|
|
||||||
- Health checks
|
|
||||||
|
|
||||||
### Planned
|
Planned next:
|
||||||
|
|
||||||
- [ ] Brewery discovery and management
|
- Brewery discovery and management
|
||||||
- [ ] Beer reviews and ratings
|
- Beer reviews and ratings
|
||||||
- [ ] Social following/followers
|
- Social follow relationships
|
||||||
- [ ] Geospatial brewery search
|
- Geospatial brewery experiences
|
||||||
- [ ] Image upload (Cloudinary)
|
- Additional frontend routes beyond the auth demo
|
||||||
- [ ] Email notifications
|
|
||||||
- [ ] OAuth integration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Highlights
|
|
||||||
|
|
||||||
### Layered Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
API Layer (Controllers)
|
|
||||||
│
|
|
||||||
Service Layer (Business Logic)
|
|
||||||
│
|
|
||||||
Infrastructure Layer (Repositories, JWT, Email)
|
|
||||||
│
|
|
||||||
Domain Layer (Entities)
|
|
||||||
│
|
|
||||||
Database (SQL Server + Stored Procedures)
|
|
||||||
```
|
|
||||||
|
|
||||||
### SQL-First Approach
|
|
||||||
|
|
||||||
- All queries via stored procedures
|
|
||||||
- No ORM (no Entity Framework)
|
|
||||||
- Version-controlled schema
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- **Password Hashing**: Argon2id (64MB memory, 4 iterations)
|
|
||||||
- **JWT Tokens**: HS256 with configurable expiration
|
|
||||||
- **Credential Rotation**: Built-in password change support
|
|
||||||
|
|
||||||
See [Architecture Guide](docs/architecture.md) for details.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
The project includes three test suites:
|
Backend suites:
|
||||||
|
|
||||||
| Suite | Type | Framework | Purpose |
|
- `API.Specs` - integration tests
|
||||||
| ---------------------- | ----------- | -------------- | ---------------------- |
|
- `Infrastructure.Repository.Tests` - repository unit tests
|
||||||
| **API.Specs** | Integration | Reqnroll (BDD) | End-to-end API testing |
|
- `Service.Auth.Tests` - service unit tests
|
||||||
| **Repository.Tests** | Unit | xUnit | Data access layer |
|
|
||||||
| **Service.Auth.Tests** | Unit | xUnit + Moq | Business logic |
|
|
||||||
|
|
||||||
**Run All Tests**:
|
Frontend suites:
|
||||||
|
|
||||||
|
- Storybook interaction tests via Vitest
|
||||||
|
- Storybook browser regression checks via Playwright
|
||||||
|
|
||||||
|
Run all backend tests with Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
||||||
```
|
```
|
||||||
|
|
||||||
**Run Individual Test Suite**:
|
See [Testing](docs/testing.md) for the full command list.
|
||||||
|
|
||||||
```bash
|
|
||||||
cd src/Core
|
|
||||||
dotnet test API/API.Specs/API.Specs.csproj
|
|
||||||
dotnet test Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj
|
|
||||||
dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Testing Guide](docs/testing.md) for more information.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker Environments
|
|
||||||
|
|
||||||
The project uses three Docker Compose configurations:
|
|
||||||
|
|
||||||
| File | Purpose | Features |
|
|
||||||
| ---------------------------- | ------------- | ------------------------------------------------- |
|
|
||||||
| **docker-compose.dev.yaml** | Development | Persistent data, hot reload, Swagger UI |
|
|
||||||
| **docker-compose.test.yaml** | CI/CD Testing | Isolated DB, auto-exit, test results export |
|
|
||||||
| **docker-compose.prod.yaml** | Production | Optimized builds, health checks, restart policies |
|
|
||||||
|
|
||||||
**Common Commands**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
docker compose -f docker-compose.dev.yaml up -d
|
|
||||||
docker compose -f docker-compose.dev.yaml logs -f api.core
|
|
||||||
docker compose -f docker-compose.dev.yaml down -v
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
|
||||||
docker compose -f docker-compose.test.yaml down -v
|
|
||||||
|
|
||||||
# Build
|
|
||||||
docker compose -f docker-compose.dev.yaml build
|
|
||||||
docker compose -f docker-compose.dev.yaml build --no-cache
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Docker Guide](docs/docker.md) for troubleshooting and advanced usage.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Required Environment Variables
|
Common active variables:
|
||||||
|
|
||||||
**Backend** (`.env.dev`):
|
- Backend: `DB_SERVER`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`
|
||||||
|
- Frontend: `API_BASE_URL`, `SESSION_SECRET`, `NODE_ENV`
|
||||||
|
|
||||||
```bash
|
See [Environment Variables](docs/environment-variables.md) for details.
|
||||||
DB_SERVER=sqlserver,1433
|
|
||||||
DB_NAME=Biergarten
|
|
||||||
DB_USER=sa
|
|
||||||
DB_PASSWORD=YourStrong!Passw0rd
|
|
||||||
JWT_SECRET=<min-32-chars>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend** (`.env.local`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BASE_URL=http://localhost:3000
|
|
||||||
NODE_ENV=development
|
|
||||||
CONFIRMATION_TOKEN_SECRET=<generated>
|
|
||||||
RESET_PASSWORD_TOKEN_SECRET=<generated>
|
|
||||||
SESSION_SECRET=<generated>
|
|
||||||
# + External services (Cloudinary, Mapbox, SparkPost)
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Environment Variables Guide](docs/environment-variables.md) for complete reference.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,27 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
This document describes the architecture patterns and design decisions for The Biergarten
|
This document describes the active architecture of The Biergarten App.
|
||||||
App.
|
|
||||||
|
|
||||||
## High-Level Overview
|
## High-Level Overview
|
||||||
|
|
||||||
The Biergarten App follows a **multi-project monorepo** architecture with clear separation
|
The Biergarten App is a monorepo with a clear split between the backend and the active
|
||||||
between backend and frontend:
|
website:
|
||||||
|
|
||||||
- **Backend**: .NET 10 Web API with SQL Server
|
- **Backend**: .NET 10 Web API with SQL Server and a layered architecture
|
||||||
- **Frontend**: Next.js with TypeScript
|
- **Frontend**: React 19 + React Router 7 website in `src/Website`
|
||||||
- **Architecture Style**: Layered architecture with SQL-first approach
|
- **Architecture Style**: Layered backend plus server-rendered React frontend
|
||||||
|
|
||||||
|
The legacy Next.js frontend has been retained in `src/Website-v1` for reference only and is
|
||||||
|
documented in [archive/legacy-website-v1.md](archive/legacy-website-v1.md).
|
||||||
|
|
||||||
## Diagrams
|
## Diagrams
|
||||||
|
|
||||||
For visual representations, see:
|
For visual representations, see:
|
||||||
|
|
||||||
- [architecture.pdf](diagrams/pdf/architecture.pdf) - Layered architecture diagram
|
- [architecture.svg](diagrams-out/architecture.svg) - Layered architecture diagram
|
||||||
- [deployment.pdf](diagrams/pdf/deployment.pdf) - Docker deployment diagram
|
- [deployment.svg](diagrams-out/deployment.svg) - Docker deployment diagram
|
||||||
- [authentication-flow.pdf](diagrams/pdf/authentication-flow.pdf) - Authentication
|
- [authentication-flow.svg](diagrams-out/authentication-flow.svg) - Authentication workflow
|
||||||
workflow
|
- [database-schema.svg](diagrams-out/database-schema.svg) - Database relationships
|
||||||
- [database-schema.pdf](diagrams/pdf/database-schema.pdf) - Database relationships
|
|
||||||
|
|
||||||
Generate diagrams with: `make diagrams`
|
|
||||||
|
|
||||||
## Backend Architecture
|
## Backend Architecture
|
||||||
|
|
||||||
@@ -217,39 +216,49 @@ public interface IAuthRepository
|
|||||||
|
|
||||||
## Frontend Architecture
|
## Frontend Architecture
|
||||||
|
|
||||||
### Next.js Application Structure
|
### Active Website (`src/Website`)
|
||||||
|
|
||||||
```
|
The current website is a React Router 7 application with server-side rendering enabled.
|
||||||
Website/src/
|
|
||||||
├── components/ # React components
|
```text
|
||||||
├── pages/ # Next.js routes
|
src/Website/
|
||||||
├── contexts/ # React context providers
|
├── app/
|
||||||
├── hooks/ # Custom React hooks
|
│ ├── components/ Shared UI such as Navbar, FormField, SubmitButton, ToastProvider
|
||||||
├── controllers/ # Business logic layer
|
│ ├── lib/ Auth helpers, schemas, and theme metadata
|
||||||
├── services/ # API communication
|
│ ├── routes/ Route modules for home, login, register, dashboard, confirm, theme
|
||||||
├── requests/ # API request builders
|
│ ├── root.tsx App shell and global providers
|
||||||
├── validation/ # Form validation schemas
|
│ └── app.css Theme tokens and global styling
|
||||||
├── config/ # Configuration & env vars
|
├── .storybook/ Storybook config and preview setup
|
||||||
└── prisma/ # Database schema (current)
|
├── stories/ Storybook stories for shared UI and themes
|
||||||
|
├── tests/playwright/ Storybook Playwright coverage
|
||||||
|
└── package.json Frontend scripts and dependencies
|
||||||
```
|
```
|
||||||
|
|
||||||
### Migration Strategy
|
### Frontend Responsibilities
|
||||||
|
|
||||||
The frontend is **transitioning** from a standalone architecture to integrate with the
|
- Render the auth demo and theme guide routes
|
||||||
.NET API:
|
- Manage cookie-backed website session state
|
||||||
|
- Call the .NET API for login, registration, token refresh, and confirmation
|
||||||
|
- Provide shared UI building blocks for forms, navigation, themes, and toasts
|
||||||
|
- Supply Storybook documentation and browser-based component verification
|
||||||
|
|
||||||
**Current State**:
|
### Theme System
|
||||||
|
|
||||||
- Uses Prisma ORM with Postgres (Neon)
|
The active website uses semantic DaisyUI theme tokens backed by four Biergarten themes:
|
||||||
- Has its own server-side API routes
|
|
||||||
- Direct database access from Next.js
|
|
||||||
|
|
||||||
**Target State**:
|
- Biergarten Lager
|
||||||
|
- Biergarten Stout
|
||||||
|
- Biergarten Cassis
|
||||||
|
- Biergarten Weizen
|
||||||
|
|
||||||
- Pure client-side Next.js app
|
All component styling should prefer semantic tokens such as `primary`, `success`,
|
||||||
- All data via .NET API
|
`surface`, and `highlight` instead of hard-coded color values.
|
||||||
- No server-side database access
|
|
||||||
- JWT-based authentication
|
### Legacy Frontend
|
||||||
|
|
||||||
|
The previous Next.js frontend has been archived at `src/Website-v1`. Active product and
|
||||||
|
engineering documentation should point to `src/Website`, while legacy notes live in
|
||||||
|
[archive/legacy-website-v1.md](archive/legacy-website-v1.md).
|
||||||
|
|
||||||
## Security Architecture
|
## Security Architecture
|
||||||
|
|
||||||
@@ -385,7 +394,7 @@ dependencies
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'sqlcmd health check']
|
test: ["CMD-SHELL", "sqlcmd health check"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
retries: 12
|
retries: 12
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
|||||||
56
docs/archive/legacy-website-v1.md
Normal file
56
docs/archive/legacy-website-v1.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Legacy Website Archive (`src/Website-v1`)
|
||||||
|
|
||||||
|
This archive captures high-level notes about the previous Biergarten frontend so active
|
||||||
|
project documentation can focus on the current website in `src/Website`.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- `src/Website-v1` is retained for historical reference only
|
||||||
|
- It is not the active frontend used by current setup, docs, or testing guidance
|
||||||
|
- New product and engineering work should target `src/Website`
|
||||||
|
|
||||||
|
## Legacy Stack Summary
|
||||||
|
|
||||||
|
The archived frontend used a different application model from the current website:
|
||||||
|
|
||||||
|
- Next.js 14
|
||||||
|
- React 18
|
||||||
|
- Prisma
|
||||||
|
- Postgres / Neon-hosted database workflows
|
||||||
|
- Next.js API routes and server-side controllers
|
||||||
|
- Additional third-party integrations such as Cloudinary, Mapbox, and SparkPost
|
||||||
|
|
||||||
|
## Why It Was Archived
|
||||||
|
|
||||||
|
The active website moved to a React Router-based frontend that talks directly to the .NET
|
||||||
|
API. As part of that shift, the main docs were updated to describe:
|
||||||
|
|
||||||
|
- `src/Website` as the active frontend
|
||||||
|
- React Router route modules and server rendering
|
||||||
|
- Storybook-based component documentation and tests
|
||||||
|
- Current frontend runtime variables: `API_BASE_URL`, `SESSION_SECRET`, and `NODE_ENV`
|
||||||
|
|
||||||
|
## Legacy Documentation Topics Moved Out of Active Docs
|
||||||
|
|
||||||
|
The following categories were removed from active documentation and intentionally archived:
|
||||||
|
|
||||||
|
- Next.js application structure guidance
|
||||||
|
- Prisma and Postgres frontend setup
|
||||||
|
- Legacy frontend environment variables
|
||||||
|
- External service setup that only applied to `src/Website-v1`
|
||||||
|
- Old frontend local setup instructions
|
||||||
|
|
||||||
|
## When To Use This Archive
|
||||||
|
|
||||||
|
Use this file only if you need to:
|
||||||
|
|
||||||
|
- inspect the historical frontend implementation
|
||||||
|
- compare old flows against the current website
|
||||||
|
- migrate or recover legacy logic from `src/Website-v1`
|
||||||
|
|
||||||
|
For all active work, use:
|
||||||
|
|
||||||
|
- [Getting Started](../getting-started.md)
|
||||||
|
- [Architecture](../architecture.md)
|
||||||
|
- [Environment Variables](../environment-variables.md)
|
||||||
|
- [Testing](../testing.md)
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
# Environment Variables
|
# Environment Variables
|
||||||
|
|
||||||
Complete documentation for all environment variables used in The Biergarten App.
|
This document covers the active environment variables used by the current Biergarten
|
||||||
|
stack.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The application uses environment variables for configuration across:
|
The application uses environment variables for:
|
||||||
|
|
||||||
- **.NET API Backend** - Database connections, JWT secrets
|
- **.NET API backend** - database connections, token secrets, runtime settings
|
||||||
- **Next.js Frontend** - External services, authentication
|
- **React Router website** - API base URL and session signing
|
||||||
- **Docker Containers** - Runtime configuration
|
- **Docker containers** - environment-specific orchestration
|
||||||
|
|
||||||
## Configuration Patterns
|
## Configuration Patterns
|
||||||
|
|
||||||
@@ -16,10 +17,10 @@ The application uses environment variables for configuration across:
|
|||||||
|
|
||||||
Direct environment variable access via `Environment.GetEnvironmentVariable()`.
|
Direct environment variable access via `Environment.GetEnvironmentVariable()`.
|
||||||
|
|
||||||
### Frontend (Next.js)
|
### Frontend (`src/Website`)
|
||||||
|
|
||||||
Centralized configuration module at `src/Website/src/config/env/index.ts` with Zod
|
The active website reads runtime values from the server environment for its auth and API
|
||||||
validation.
|
integration.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
@@ -128,91 +129,38 @@ ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
|
|||||||
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
|
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
|
||||||
```
|
```
|
||||||
|
|
||||||
## Frontend Variables (Next.js)
|
## Frontend Variables (`src/Website`)
|
||||||
|
|
||||||
Create `.env.local` in the `Website/` directory.
|
The active website does not use the old Next.js/Prisma environment model. Its core runtime
|
||||||
|
variables are:
|
||||||
### Base Configuration
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BASE_URL=http://localhost:3000 # Application base URL
|
API_BASE_URL=http://localhost:8080 # Base URL for the .NET API
|
||||||
NODE_ENV=development # Environment: development, production, test
|
SESSION_SECRET=<generated-secret> # Cookie session signing secret
|
||||||
|
NODE_ENV=development # Standard Node runtime mode
|
||||||
```
|
```
|
||||||
|
|
||||||
### Authentication & Sessions
|
### Frontend Variable Details
|
||||||
|
|
||||||
```bash
|
#### `API_BASE_URL`
|
||||||
# Token signing secrets (use openssl rand -base64 127)
|
|
||||||
CONFIRMATION_TOKEN_SECRET=<generated-secret> # Email confirmation tokens
|
|
||||||
RESET_PASSWORD_TOKEN_SECRET=<generated-secret> # Password reset tokens
|
|
||||||
SESSION_SECRET=<generated-secret> # Session cookie signing
|
|
||||||
|
|
||||||
# Session configuration
|
- **Required**: Yes for local development
|
||||||
SESSION_TOKEN_NAME=biergarten # Cookie name (optional)
|
- **Default in code**: `http://localhost:8080`
|
||||||
SESSION_MAX_AGE=604800 # Cookie max age in seconds (optional, default: 1 week)
|
- **Used by**: `src/Website/app/lib/auth.server.ts`
|
||||||
```
|
- **Purpose**: Routes website auth actions to the .NET API
|
||||||
|
|
||||||
**Security Requirements**:
|
#### `SESSION_SECRET`
|
||||||
|
|
||||||
- All secrets should be 127+ characters
|
- **Required**: Strongly recommended in all environments
|
||||||
- Generate using cryptographically secure random functions
|
- **Default in local code path**: `dev-secret-change-me`
|
||||||
- Never reuse secrets across environments
|
- **Used by**: React Router cookie session storage in `auth.server.ts`
|
||||||
- Rotate secrets periodically in production
|
- **Purpose**: Signs and validates the website session cookie
|
||||||
|
|
||||||
### Database (Current - Prisma/Postgres)
|
#### `NODE_ENV`
|
||||||
|
|
||||||
**Note**: Frontend currently uses Neon Postgres. Will migrate to .NET API.
|
- **Required**: No
|
||||||
|
- **Typical values**: `development`, `production`, `test`
|
||||||
```bash
|
- **Purpose**: Controls secure cookie behavior and runtime mode
|
||||||
POSTGRES_PRISMA_URL=postgresql://user:pass@host/db?pgbouncer=true # Pooled connection
|
|
||||||
POSTGRES_URL_NON_POOLING=postgresql://user:pass@host/db # Direct connection (migrations)
|
|
||||||
SHADOW_DATABASE_URL=postgresql://user:pass@host/shadow_db # Prisma shadow DB (optional)
|
|
||||||
```
|
|
||||||
|
|
||||||
### External Services
|
|
||||||
|
|
||||||
#### Cloudinary (Image Hosting)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name # Public, client-accessible
|
|
||||||
CLOUDINARY_KEY=your-api-key # Server-side API key
|
|
||||||
CLOUDINARY_SECRET=your-api-secret # Server-side secret
|
|
||||||
```
|
|
||||||
|
|
||||||
**Setup Steps**:
|
|
||||||
|
|
||||||
1. Sign up at [cloudinary.com](https://cloudinary.com)
|
|
||||||
2. Navigate to Dashboard
|
|
||||||
3. Copy Cloud Name, API Key, and API Secret
|
|
||||||
|
|
||||||
**Note**: `NEXT_PUBLIC_` prefix makes variable accessible in client-side code.
|
|
||||||
|
|
||||||
#### Mapbox (Maps & Geocoding)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MAPBOX_ACCESS_TOKEN=pk.your-public-token
|
|
||||||
```
|
|
||||||
|
|
||||||
**Setup Steps**:
|
|
||||||
|
|
||||||
1. Create account at [mapbox.com](https://mapbox.com)
|
|
||||||
2. Navigate to Account → Tokens
|
|
||||||
3. Create new token with public scopes
|
|
||||||
4. Copy access token
|
|
||||||
|
|
||||||
#### SparkPost (Email Service)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SPARKPOST_API_KEY=your-api-key
|
|
||||||
SPARKPOST_SENDER_ADDRESS=noreply@yourdomain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**Setup Steps**:
|
|
||||||
|
|
||||||
1. Sign up at [sparkpost.com](https://sparkpost.com)
|
|
||||||
2. Verify sending domain or use sandbox
|
|
||||||
3. Create API key with "Send via SMTP" permission
|
|
||||||
4. Configure sender address (must match verified domain)
|
|
||||||
|
|
||||||
### Admin Account (Seeding)
|
### Admin Account (Seeding)
|
||||||
|
|
||||||
@@ -258,72 +206,42 @@ cp .env.example .env.dev
|
|||||||
# Edit .env.dev with your values
|
# Edit .env.dev with your values
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Legacy Frontend Variables
|
||||||
|
|
||||||
|
Variables for the archived Next.js frontend (`src/Website-v1`) have been removed from this
|
||||||
|
active reference. See [archive/legacy-website-v1.md](archive/legacy-website-v1.md) if you
|
||||||
|
need the legacy Prisma, Cloudinary, Mapbox, or SparkPost notes.
|
||||||
|
|
||||||
**Docker Compose Mapping**:
|
**Docker Compose Mapping**:
|
||||||
|
|
||||||
- `docker-compose.dev.yaml` → `.env.dev`
|
- `docker-compose.dev.yaml` → `.env.dev`
|
||||||
- `docker-compose.test.yaml` → `.env.test`
|
- `docker-compose.test.yaml` → `.env.test`
|
||||||
- `docker-compose.prod.yaml` → `.env.prod`
|
- `docker-compose.prod.yaml` → `.env.prod`
|
||||||
|
|
||||||
### Frontend (Website Directory)
|
|
||||||
|
|
||||||
```
|
|
||||||
.env.local # Local development (gitignored)
|
|
||||||
.env.production # Production (gitignored)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Setup**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd Website
|
|
||||||
touch .env.local
|
|
||||||
# Add frontend variables
|
|
||||||
```
|
|
||||||
|
|
||||||
## Variable Reference Table
|
## Variable Reference Table
|
||||||
|
|
||||||
| Variable | Backend | Frontend | Docker | Required | Notes |
|
| Variable | Backend | Frontend | Docker | Required | Notes |
|
||||||
| ----------------------------------- | :-----: | :------: | :----: | :------: | ------------------------- |
|
| ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- |
|
||||||
| **Database** |
|
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
|
||||||
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
|
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
|
||||||
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
|
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
|
||||||
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
|
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
|
||||||
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
|
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
|
||||||
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
|
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to `True` |
|
||||||
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to True |
|
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token signing |
|
||||||
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing |
|
||||||
| **Authentication (Backend - JWT)** |
|
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
|
||||||
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token secret |
|
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
|
||||||
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token secret |
|
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
|
||||||
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token secret |
|
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
|
||||||
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
|
| `NODE_ENV` | | ✓ | | No | Runtime mode |
|
||||||
| **Authentication (Frontend)** |
|
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
|
||||||
| `CONFIRMATION_TOKEN_SECRET` | | ✓ | | Yes | Email confirmation |
|
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
||||||
| `RESET_PASSWORD_TOKEN_SECRET` | | ✓ | | Yes | Password reset |
|
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
||||||
| `SESSION_SECRET` | | ✓ | | Yes | Session signing |
|
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
||||||
| `SESSION_TOKEN_NAME` | | ✓ | | No | Default: "biergarten" |
|
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
|
||||||
| `SESSION_MAX_AGE` | | ✓ | | No | Default: 604800 |
|
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
|
||||||
| **Base Configuration** |
|
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
|
||||||
| `BASE_URL` | | ✓ | | Yes | App base URL |
|
|
||||||
| `NODE_ENV` | | ✓ | | Yes | Node environment |
|
|
||||||
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
|
||||||
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
|
||||||
| **Database (Frontend - Current)** |
|
|
||||||
| `POSTGRES_PRISMA_URL` | | ✓ | | Yes | Pooled connection |
|
|
||||||
| `POSTGRES_URL_NON_POOLING` | | ✓ | | Yes | Direct connection |
|
|
||||||
| `SHADOW_DATABASE_URL` | | ✓ | | No | Prisma shadow DB |
|
|
||||||
| **External Services** |
|
|
||||||
| `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME` | | ✓ | | Yes | Public, client-side |
|
|
||||||
| `CLOUDINARY_KEY` | | ✓ | | Yes | Server-side |
|
|
||||||
| `CLOUDINARY_SECRET` | | ✓ | | Yes | Server-side |
|
|
||||||
| `MAPBOX_ACCESS_TOKEN` | | ✓ | | Yes | Maps/geocoding |
|
|
||||||
| `SPARKPOST_API_KEY` | | ✓ | | Yes | Email service |
|
|
||||||
| `SPARKPOST_SENDER_ADDRESS` | | ✓ | | Yes | From address |
|
|
||||||
| **Other** |
|
|
||||||
| `ADMIN_PASSWORD` | | ✓ | | No | Seeding only |
|
|
||||||
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test only |
|
|
||||||
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
|
|
||||||
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
|
|
||||||
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
|
|
||||||
|
|
||||||
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`, `DB_NAME`,
|
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`, `DB_NAME`,
|
||||||
`DB_USER`, `DB_PASSWORD`) must be provided.
|
`DB_USER`, `DB_PASSWORD`) must be provided.
|
||||||
@@ -340,13 +258,12 @@ Variables are validated at startup:
|
|||||||
|
|
||||||
### Frontend Validation
|
### Frontend Validation
|
||||||
|
|
||||||
Zod schemas validate variables at runtime:
|
The active website relies on runtime defaults for local development and the surrounding
|
||||||
|
server environment in deployed environments.
|
||||||
|
|
||||||
- Type checking (string, number, URL, etc.)
|
- `API_BASE_URL` defaults to `http://localhost:8080`
|
||||||
- Format validation (email, URL patterns)
|
- `SESSION_SECRET` falls back to a development-only local secret
|
||||||
- Required vs optional enforcement
|
- `NODE_ENV` controls secure cookie behavior
|
||||||
|
|
||||||
**Location**: `src/Website/src/config/env/index.ts`
|
|
||||||
|
|
||||||
## Example Configuration Files
|
## Example Configuration Files
|
||||||
|
|
||||||
@@ -378,28 +295,10 @@ ACCEPT_EULA=Y
|
|||||||
MSSQL_PID=Express
|
MSSQL_PID=Express
|
||||||
```
|
```
|
||||||
|
|
||||||
### `.env.local` (Frontend)
|
### Frontend local runtime example
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Base
|
API_BASE_URL=http://localhost:8080
|
||||||
BASE_URL=http://localhost:3000
|
|
||||||
NODE_ENV=development
|
|
||||||
|
|
||||||
# Authentication
|
|
||||||
SESSION_SECRET=<generated-with-openssl>
|
SESSION_SECRET=<generated-with-openssl>
|
||||||
|
NODE_ENV=development
|
||||||
# Database (current Prisma setup)
|
|
||||||
POSTGRES_PRISMA_URL=postgresql://user:pass@db.neon.tech/biergarten?pgbouncer=true
|
|
||||||
POSTGRES_URL_NON_POOLING=postgresql://user:pass@db.neon.tech/biergarten
|
|
||||||
|
|
||||||
# External Services
|
|
||||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=my-cloud
|
|
||||||
CLOUDINARY_KEY=123456789012345
|
|
||||||
CLOUDINARY_SECRET=abcdefghijklmnopqrstuvwxyz
|
|
||||||
MAPBOX_ACCESS_TOKEN=pk.eyJ...
|
|
||||||
SPARKPOST_API_KEY=abc123...
|
|
||||||
SPARKPOST_SENDER_ADDRESS=noreply@biergarten.app
|
|
||||||
|
|
||||||
# Admin (for seeding)
|
|
||||||
ADMIN_PASSWORD=Admin_Dev_Password_123!
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
# Getting Started
|
# Getting Started
|
||||||
|
|
||||||
This guide will help you set up and run The Biergarten App in your development
|
This guide covers local setup for the current Biergarten stack: the .NET backend in
|
||||||
environment.
|
`src/Core` and the active React Router frontend in `src/Website`.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
Before you begin, ensure you have the following installed:
|
- **.NET SDK 10+**
|
||||||
|
- **Node.js 18+**
|
||||||
|
- **Docker Desktop** or equivalent Docker Engine setup
|
||||||
|
- **Java 8+** if you want to regenerate PlantUML diagrams
|
||||||
|
|
||||||
- **.NET SDK 10+** - [Download](https://dotnet.microsoft.com/download)
|
## Recommended Path: Docker for Backend, Node for Frontend
|
||||||
- **Node.js 18+** - [Download](https://nodejs.org/)
|
|
||||||
- **Docker Desktop** - [Download](https://www.docker.com/products/docker-desktop)
|
|
||||||
(recommended)
|
|
||||||
- **Java 8+** - Required for generating diagrams from PlantUML (optional)
|
|
||||||
|
|
||||||
## Quick Start with Docker (Recommended)
|
|
||||||
|
|
||||||
### 1. Clone the Repository
|
### 1. Clone the Repository
|
||||||
|
|
||||||
@@ -22,174 +19,120 @@ git clone <repository-url>
|
|||||||
cd the-biergarten-app
|
cd the-biergarten-app
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configure Environment Variables
|
### 2. Configure Backend Environment Variables
|
||||||
|
|
||||||
Copy the example environment file:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.dev
|
cp .env.example .env.dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `.env.dev` with your configuration:
|
At minimum, ensure `.env.dev` includes valid database and token values:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Database (component-based for Docker)
|
|
||||||
DB_SERVER=sqlserver,1433
|
DB_SERVER=sqlserver,1433
|
||||||
DB_NAME=Biergarten
|
DB_NAME=Biergarten
|
||||||
DB_USER=sa
|
DB_USER=sa
|
||||||
DB_PASSWORD=YourStrong!Passw0rd
|
DB_PASSWORD=YourStrong!Passw0rd
|
||||||
|
ACCESS_TOKEN_SECRET=<generated>
|
||||||
# JWT Authentication
|
REFRESH_TOKEN_SECRET=<generated>
|
||||||
JWT_SECRET=your-secret-key-minimum-32-characters-required
|
CONFIRMATION_TOKEN_SECRET=<generated>
|
||||||
|
WEBSITE_BASE_URL=http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
> For a complete list of environment variables, see
|
See [Environment Variables](environment-variables.md) for the full list.
|
||||||
> [Environment Variables](environment-variables.md).
|
|
||||||
|
|
||||||
### 3. Start the Development Environment
|
### 3. Start the Backend Stack
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yaml up -d
|
docker compose -f docker-compose.dev.yaml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
This command will:
|
This starts SQL Server, migrations, seeding, and the API.
|
||||||
|
|
||||||
- Start SQL Server container
|
Available endpoints:
|
||||||
- Run database migrations
|
|
||||||
- Seed initial data
|
|
||||||
- Start the API on http://localhost:8080
|
|
||||||
|
|
||||||
### 4. Access the API
|
- API Swagger: http://localhost:8080/swagger
|
||||||
|
- Health Check: http://localhost:8080/health
|
||||||
|
|
||||||
- **Swagger UI**: http://localhost:8080/swagger
|
### 4. Start the Active Frontend
|
||||||
- **Health Check**: http://localhost:8080/health
|
|
||||||
|
|
||||||
### 5. View Logs
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# All services
|
cd src/Website
|
||||||
docker compose -f docker-compose.dev.yaml logs -f
|
npm install
|
||||||
|
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret-change-me npm run dev
|
||||||
# Specific service
|
|
||||||
docker compose -f docker-compose.dev.yaml logs -f api.core
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Stop the Environment
|
The website will be available at the local address printed by React Router dev.
|
||||||
|
|
||||||
|
Required frontend runtime variables for local work:
|
||||||
|
|
||||||
|
- `API_BASE_URL` - Base URL for the .NET API
|
||||||
|
- `SESSION_SECRET` - Cookie session signing secret for the website server
|
||||||
|
|
||||||
|
### 5. Optional: Run Storybook
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yaml down
|
cd src/Website
|
||||||
|
npm run storybook
|
||||||
|
```
|
||||||
|
|
||||||
# Remove volumes (fresh start)
|
Storybook runs at http://localhost:6006 by default.
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yaml logs -f
|
||||||
|
docker compose -f docker-compose.dev.yaml down
|
||||||
docker compose -f docker-compose.dev.yaml down -v
|
docker compose -f docker-compose.dev.yaml down -v
|
||||||
```
|
```
|
||||||
|
|
||||||
## Manual Setup (Without Docker)
|
### Frontend
|
||||||
|
|
||||||
If you prefer to run services locally without Docker:
|
|
||||||
|
|
||||||
### Backend Setup
|
|
||||||
|
|
||||||
#### 1. Start SQL Server
|
|
||||||
|
|
||||||
You can use a local SQL Server instance or a cloud-hosted one. Ensure it's accessible and
|
|
||||||
you have the connection details.
|
|
||||||
|
|
||||||
#### 2. Set Environment Variables
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS/Linux
|
cd src/Website
|
||||||
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
npm run lint
|
||||||
export JWT_SECRET="your-secret-key-minimum-32-characters-required"
|
npm run typecheck
|
||||||
|
npm run format:check
|
||||||
# Windows PowerShell
|
npm run test:storybook
|
||||||
$env:DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
npm run test:storybook:playwright
|
||||||
$env:JWT_SECRET="your-secret-key-minimum-32-characters-required"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. Run Database Migrations
|
## Manual Backend Setup
|
||||||
|
|
||||||
|
If you do not want to use Docker, you can run the backend locally.
|
||||||
|
|
||||||
|
### 1. Set Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
||||||
|
export ACCESS_TOKEN_SECRET="<generated>"
|
||||||
|
export REFRESH_TOKEN_SECRET="<generated>"
|
||||||
|
export CONFIRMATION_TOKEN_SECRET="<generated>"
|
||||||
|
export WEBSITE_BASE_URL="http://localhost:3000"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run Migrations and Seed
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src/Core
|
cd src/Core
|
||||||
dotnet run --project Database/Database.Migrations/Database.Migrations.csproj
|
dotnet run --project Database/Database.Migrations/Database.Migrations.csproj
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Seed the Database
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet run --project Database/Database.Seed/Database.Seed.csproj
|
dotnet run --project Database/Database.Seed/Database.Seed.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 5. Start the API
|
### 3. Start the API
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet run --project API/API.Core/API.Core.csproj
|
dotnet run --project API/API.Core/API.Core.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
The API will be available at http://localhost:5000 (or the port specified in
|
## Legacy Frontend Note
|
||||||
launchSettings.json).
|
|
||||||
|
|
||||||
### Frontend Setup
|
|
||||||
|
|
||||||
> **Note**: The frontend is currently transitioning from its standalone Prisma/Postgres
|
|
||||||
> backend to the .NET API. Some features may still use the old backend.
|
|
||||||
|
|
||||||
#### 1. Navigate to Website Directory
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd Website
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Create Environment File
|
|
||||||
|
|
||||||
Create `.env.local` with frontend variables. See
|
|
||||||
[Environment Variables - Frontend](environment-variables.md#frontend-variables) for the
|
|
||||||
complete list.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BASE_URL=http://localhost:3000
|
|
||||||
NODE_ENV=development
|
|
||||||
|
|
||||||
# Generate secrets
|
|
||||||
CONFIRMATION_TOKEN_SECRET=$(openssl rand -base64 127)
|
|
||||||
RESET_PASSWORD_TOKEN_SECRET=$(openssl rand -base64 127)
|
|
||||||
SESSION_SECRET=$(openssl rand -base64 127)
|
|
||||||
|
|
||||||
# External services (you'll need to register for these)
|
|
||||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name
|
|
||||||
CLOUDINARY_KEY=your-api-key
|
|
||||||
CLOUDINARY_SECRET=your-api-secret
|
|
||||||
NEXT_PUBLIC_MAPBOX_KEY=your-mapbox-token
|
|
||||||
|
|
||||||
# Database URL (current Prisma setup)
|
|
||||||
DATABASE_URL=your-postgres-connection-string
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Install Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Run Prisma Migrations
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma generate
|
|
||||||
npx prisma migrate dev
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. Start Development Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The frontend will be available at http://localhost:3000.
|
|
||||||
|
|
||||||
|
The previous Next.js frontend now lives in `src/Website-v1` and is not the active website.
|
||||||
|
Legacy setup details have been moved to [docs/archive/legacy-website-v1.md](archive/legacy-website-v1.md).
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
- **Test the API**: Visit http://localhost:8080/swagger and try the endpoints
|
- Review [Architecture](architecture.md)
|
||||||
- **Run Tests**: See [Testing Guide](testing.md)
|
- Run backend and frontend checks from [Testing](testing.md)
|
||||||
- **Learn the Architecture**: Read [Architecture Overview](architecture.md)
|
- Use [Docker Guide](docker.md) for container troubleshooting
|
||||||
- **Understand Docker Setup**: See [Docker Guide](docker.md)
|
|
||||||
- **Database Details**: Check [Database Schema](database.md)
|
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ This document describes the testing strategy and how to run tests for The Bierga
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The project uses a multi-layered testing approach:
|
The project uses a multi-layered testing approach across backend and frontend:
|
||||||
|
|
||||||
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin)
|
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin)
|
||||||
- **Infrastructure.Repository.Tests** - Unit tests for data access layer
|
- **Infrastructure.Repository.Tests** - Unit tests for data access layer
|
||||||
- **Service.Auth.Tests** - Unit tests for authentication business logic
|
- **Service.Auth.Tests** - Unit tests for authentication business logic
|
||||||
|
- **Storybook Vitest project** - Browser-based interaction tests for shared website stories
|
||||||
|
- **Storybook Playwright suite** - Browser checks against Storybook-rendered components
|
||||||
|
|
||||||
## Running Tests with Docker (Recommended)
|
## Running Tests with Docker (Recommended)
|
||||||
|
|
||||||
@@ -86,6 +88,33 @@ dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
|
|||||||
|
|
||||||
- No database required (uses Moq for mocking)
|
- No database required (uses Moq for mocking)
|
||||||
|
|
||||||
|
### Frontend Storybook Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Website
|
||||||
|
npm install
|
||||||
|
npm run test:storybook
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose**:
|
||||||
|
|
||||||
|
- Verifies shared stories such as form fields, submit buttons, navbar states, toasts, and the theme gallery
|
||||||
|
- Runs in browser mode via Vitest and Storybook integration
|
||||||
|
|
||||||
|
### Frontend Playwright Storybook Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Website
|
||||||
|
npm install
|
||||||
|
npm run test:storybook:playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements**:
|
||||||
|
|
||||||
|
- Storybook dependencies installed
|
||||||
|
- Playwright browser dependencies installed
|
||||||
|
- The command will start or reuse the Storybook server defined in `playwright.storybook.config.ts`
|
||||||
|
|
||||||
## Test Coverage
|
## Test Coverage
|
||||||
|
|
||||||
### Current Coverage
|
### Current Coverage
|
||||||
@@ -112,6 +141,14 @@ dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
|
|||||||
- Register service with validation
|
- Register service with validation
|
||||||
- Business logic for authentication flow
|
- Business logic for authentication flow
|
||||||
|
|
||||||
|
**Frontend UI Coverage**:
|
||||||
|
|
||||||
|
- Shared submit button states
|
||||||
|
- Form field happy path and error presentation
|
||||||
|
- Navbar guest, authenticated, and mobile behavior
|
||||||
|
- Theme gallery rendering across Biergarten themes
|
||||||
|
- Toast interactions and themed notification display
|
||||||
|
|
||||||
### Planned Coverage
|
### Planned Coverage
|
||||||
|
|
||||||
- [ ] Email verification workflow
|
- [ ] Email verification workflow
|
||||||
@@ -121,6 +158,7 @@ dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
|
|||||||
- [ ] Beer post operations
|
- [ ] Beer post operations
|
||||||
- [ ] User follow/unfollow
|
- [ ] User follow/unfollow
|
||||||
- [ ] Image upload service
|
- [ ] Image upload service
|
||||||
|
- [ ] Frontend route integration coverage beyond Storybook stories
|
||||||
|
|
||||||
## Testing Frameworks & Tools
|
## Testing Frameworks & Tools
|
||||||
|
|
||||||
@@ -254,6 +292,15 @@ Exit codes:
|
|||||||
- `0` - All tests passed
|
- `0` - All tests passed
|
||||||
- Non-zero - Test failures occurred
|
- Non-zero - Test failures occurred
|
||||||
|
|
||||||
|
Frontend UI checks should also be included in CI for the active website workspace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Website
|
||||||
|
npm ci
|
||||||
|
npm run test:storybook
|
||||||
|
npm run test:storybook:playwright
|
||||||
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Tests Failing Due to Database Connection
|
### Tests Failing Due to Database Connection
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ USE Biergarten;
|
|||||||
CREATE TABLE dbo.UserAccount
|
CREATE TABLE dbo.UserAccount
|
||||||
(
|
(
|
||||||
UserAccountID UNIQUEIDENTIFIER
|
UserAccountID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
||||||
|
|
||||||
Username VARCHAR(64) NOT NULL,
|
Username VARCHAR(64) NOT NULL,
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ CREATE TABLE dbo.UserAccount
|
|||||||
|
|
||||||
UpdatedAt DATETIME,
|
UpdatedAt DATETIME,
|
||||||
|
|
||||||
DateOfBirth DATETIME NOT NULL,
|
DateOfBirth DATE NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
@@ -49,7 +49,6 @@ CREATE TABLE dbo.UserAccount
|
|||||||
|
|
||||||
CONSTRAINT AK_Email
|
CONSTRAINT AK_Email
|
||||||
UNIQUE (Email)
|
UNIQUE (Email)
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -109,7 +108,7 @@ CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
|
|||||||
|
|
||||||
CONSTRAINT AK_UserAvatar_UserAccountID
|
CONSTRAINT AK_UserAvatar_UserAccountID
|
||||||
UNIQUE (UserAccountID)
|
UNIQUE (UserAccountID)
|
||||||
)
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
||||||
ON UserAvatar(UserAccountID);
|
ON UserAvatar(UserAccountID);
|
||||||
@@ -125,8 +124,7 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
|
|||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
VerificationDateTime DATETIME NOT NULL
|
VerificationDateTime DATETIME NOT NULL
|
||||||
CONSTRAINT DF_VerificationDateTime
|
CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
|
||||||
DEFAULT GETDATE(),
|
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
@@ -155,13 +153,13 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
|||||||
|
|
||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE() NOT NULL,
|
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Expiry DATETIME
|
Expiry DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()) NOT NULL,
|
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
|
||||||
|
|
||||||
Hash NVARCHAR(MAX) NOT NULL,
|
Hash NVARCHAR(256) NOT NULL,
|
||||||
-- uses argon2
|
-- uses argon2
|
||||||
|
|
||||||
IsRevoked BIT NOT NULL
|
IsRevoked BIT NOT NULL
|
||||||
@@ -177,12 +175,16 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
|||||||
CONSTRAINT FK_UserCredential_UserAccount
|
CONSTRAINT FK_UserCredential_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount(UserAccountID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
||||||
ON UserCredential(UserAccountID);
|
ON UserCredential(UserAccountID);
|
||||||
|
|
||||||
|
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
||||||
|
ON UserCredential(UserAccountID, IsRevoked, Expiry)
|
||||||
|
INCLUDE (Hash);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -195,8 +197,8 @@ CREATE TABLE UserFollow
|
|||||||
|
|
||||||
FollowingID UNIQUEIDENTIFIER NOT NULL,
|
FollowingID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE() NOT NULL,
|
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
@@ -205,11 +207,13 @@ CREATE TABLE UserFollow
|
|||||||
|
|
||||||
CONSTRAINT FK_UserFollow_UserAccount
|
CONSTRAINT FK_UserFollow_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount(UserAccountID),
|
REFERENCES UserAccount(UserAccountID)
|
||||||
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT FK_UserFollow_UserAccountFollowing
|
CONSTRAINT FK_UserFollow_UserAccountFollowing
|
||||||
FOREIGN KEY (FollowingID)
|
FOREIGN KEY (FollowingID)
|
||||||
REFERENCES UserAccount(UserAccountID),
|
REFERENCES UserAccount(UserAccountID)
|
||||||
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT CK_CannotFollowOwnAccount
|
CONSTRAINT CK_CannotFollowOwnAccount
|
||||||
CHECK (UserAccountID != FollowingID)
|
CHECK (UserAccountID != FollowingID)
|
||||||
@@ -221,7 +225,6 @@ CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
|||||||
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
||||||
ON UserFollow(FollowingID, UserAccountID);
|
ON UserFollow(FollowingID, UserAccountID);
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -240,7 +243,7 @@ CREATE TABLE Country
|
|||||||
PRIMARY KEY (CountryID),
|
PRIMARY KEY (CountryID),
|
||||||
|
|
||||||
CONSTRAINT AK_Country_ISO3166_1
|
CONSTRAINT AK_Country_ISO3166_1
|
||||||
UNIQUE (ISO3166_1)
|
UNIQUE (ISO3166_1)
|
||||||
);
|
);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -299,7 +302,6 @@ CREATE TABLE City
|
|||||||
CREATE NONCLUSTERED INDEX IX_City_StateProvince
|
CREATE NONCLUSTERED INDEX IX_City_StateProvince
|
||||||
ON City(StateProvinceID);
|
ON City(StateProvinceID);
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -308,6 +310,8 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
|||||||
BreweryPostID UNIQUEIDENTIFIER
|
BreweryPostID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
|
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
|
||||||
|
|
||||||
|
BreweryName NVARCHAR(256) NOT NULL,
|
||||||
|
|
||||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Description NVARCHAR(512) NOT NULL,
|
Description NVARCHAR(512) NOT NULL,
|
||||||
@@ -325,15 +329,15 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
|||||||
CONSTRAINT FK_BreweryPost_UserAccount
|
CONSTRAINT FK_BreweryPost_UserAccount
|
||||||
FOREIGN KEY (PostedByID)
|
FOREIGN KEY (PostedByID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount(UserAccountID)
|
||||||
ON DELETE NO ACTION,
|
ON DELETE NO ACTION
|
||||||
|
);
|
||||||
)
|
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
||||||
ON BreweryPost(PostedByID);
|
ON BreweryPost(PostedByID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
CREATE TABLE BreweryPostLocation
|
CREATE TABLE BreweryPostLocation
|
||||||
(
|
(
|
||||||
BreweryPostLocationID UNIQUEIDENTIFIER
|
BreweryPostLocationID UNIQUEIDENTIFIER
|
||||||
@@ -349,7 +353,7 @@ CREATE TABLE BreweryPostLocation
|
|||||||
|
|
||||||
CityID UNIQUEIDENTIFIER NOT NULL,
|
CityID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Coordinates GEOGRAPHY NOT NULL,
|
Coordinates GEOGRAPHY NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
@@ -362,7 +366,11 @@ CREATE TABLE BreweryPostLocation
|
|||||||
CONSTRAINT FK_BreweryPostLocation_BreweryPost
|
CONSTRAINT FK_BreweryPostLocation_BreweryPost
|
||||||
FOREIGN KEY (BreweryPostID)
|
FOREIGN KEY (BreweryPostID)
|
||||||
REFERENCES BreweryPost(BreweryPostID)
|
REFERENCES BreweryPost(BreweryPostID)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
|
CONSTRAINT FK_BreweryPostLocation_City
|
||||||
|
FOREIGN KEY (CityID)
|
||||||
|
REFERENCES City(CityID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
||||||
@@ -371,6 +379,18 @@ CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
|||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
||||||
ON BreweryPostLocation(CityID);
|
ON BreweryPostLocation(CityID);
|
||||||
|
|
||||||
|
-- To assess when the time comes:
|
||||||
|
|
||||||
|
-- This would allow for efficient spatial queries to find breweries within a certain distance of a location, but it adds overhead to insert/update operations.
|
||||||
|
|
||||||
|
-- CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates
|
||||||
|
-- ON BreweryPostLocation(Coordinates)
|
||||||
|
-- USING GEOGRAPHY_GRID
|
||||||
|
-- WITH (
|
||||||
|
-- GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM),
|
||||||
|
-- CELLS_PER_OBJECT = 16
|
||||||
|
-- );
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -403,13 +423,14 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
||||||
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
||||||
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
CREATE TABLE BeerStyle
|
CREATE TABLE BeerStyle
|
||||||
(
|
(
|
||||||
BeerStyleID UNIQUEIDENTIFIER
|
BeerStyleID UNIQUEIDENTIFIER
|
||||||
@@ -444,7 +465,7 @@ CREATE TABLE BeerPost
|
|||||||
-- Alcohol By Volume (typically 0-67%)
|
-- Alcohol By Volume (typically 0-67%)
|
||||||
|
|
||||||
IBU INT NOT NULL,
|
IBU INT NOT NULL,
|
||||||
-- International Bitterness Units (typically 0-100)
|
-- International Bitterness Units (typically 0-120)
|
||||||
|
|
||||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
@@ -464,7 +485,8 @@ CREATE TABLE BeerPost
|
|||||||
|
|
||||||
CONSTRAINT FK_BeerPost_PostedBy
|
CONSTRAINT FK_BeerPost_PostedBy
|
||||||
FOREIGN KEY (PostedByID)
|
FOREIGN KEY (PostedByID)
|
||||||
REFERENCES UserAccount(UserAccountID),
|
REFERENCES UserAccount(UserAccountID)
|
||||||
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPost_BeerStyle
|
CONSTRAINT FK_BeerPost_BeerStyle
|
||||||
FOREIGN KEY (BeerStyleID)
|
FOREIGN KEY (BeerStyleID)
|
||||||
@@ -522,10 +544,10 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
||||||
ON BeerPostPhoto(PhotoID, BeerPostID);
|
ON BeerPostPhoto(PhotoID, BeerPostID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
||||||
ON BeerPostPhoto(BeerPostID, PhotoID);
|
ON BeerPostPhoto(BeerPostID, PhotoID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -539,17 +561,35 @@ CREATE TABLE BeerPostComment
|
|||||||
|
|
||||||
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
|
CommentedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Rating INT NOT NULL,
|
Rating INT NOT NULL,
|
||||||
|
|
||||||
|
CreatedAt DATETIME NOT NULL
|
||||||
|
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
|
UpdatedAt DATETIME NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BeerPostComment
|
CONSTRAINT PK_BeerPostComment
|
||||||
PRIMARY KEY (BeerPostCommentID),
|
PRIMARY KEY (BeerPostCommentID),
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPostComment_BeerPost
|
CONSTRAINT FK_BeerPostComment_BeerPost
|
||||||
FOREIGN KEY (BeerPostID) REFERENCES BeerPost(BeerPostID)
|
FOREIGN KEY (BeerPostID)
|
||||||
)
|
REFERENCES BeerPost(BeerPostID),
|
||||||
|
|
||||||
|
CONSTRAINT FK_BeerPostComment_UserAccount
|
||||||
|
FOREIGN KEY (CommentedByID)
|
||||||
|
REFERENCES UserAccount(UserAccountID)
|
||||||
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
|
CONSTRAINT CHK_BeerPostComment_Rating
|
||||||
|
CHECK (Rating BETWEEN 1 AND 5)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
||||||
ON BeerPostComment(BeerPostID)
|
ON BeerPostComment(BeerPostID);
|
||||||
|
|
||||||
|
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
|
||||||
|
ON BeerPostComment(CommentedByID);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
|
||||||
|
@BreweryName NVARCHAR(256),
|
||||||
|
@Description NVARCHAR(512),
|
||||||
|
@PostedByID UNIQUEIDENTIFIER,
|
||||||
|
@CityID UNIQUEIDENTIFIER,
|
||||||
|
@AddressLine1 NVARCHAR(256),
|
||||||
|
@AddressLine2 NVARCHAR(256) = NULL,
|
||||||
|
@PostalCode NVARCHAR(20),
|
||||||
|
@Coordinates GEOGRAPHY = NULL
|
||||||
|
)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
|
||||||
|
IF @BreweryName IS NULL
|
||||||
|
THROW 50001, 'Brewery name cannot be null.', 1;
|
||||||
|
|
||||||
|
IF @Description IS NULL
|
||||||
|
THROW 50002, 'Brewery description cannot be null.', 1;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1
|
||||||
|
FROM dbo.UserAccount
|
||||||
|
WHERE UserAccountID = @PostedByID)
|
||||||
|
THROW 50404, 'User not found.', 1;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1
|
||||||
|
FROM dbo.City
|
||||||
|
WHERE CityID = @CityID)
|
||||||
|
THROW 50404, 'City not found.', 1;
|
||||||
|
|
||||||
|
DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID();
|
||||||
|
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
INSERT INTO dbo.BreweryPost
|
||||||
|
(BreweryPostID, BreweryName, Description, PostedByID)
|
||||||
|
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
|
||||||
|
|
||||||
|
INSERT INTO dbo.BreweryPostLocation
|
||||||
|
(@NewBreweryID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
|
||||||
|
VALUES (@NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
END
|
||||||
7
src/Website/.prettierignore
Normal file
7
src/Website/.prettierignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
build
|
||||||
|
node_modules
|
||||||
|
.react-router
|
||||||
|
package-lock.json
|
||||||
|
storybook-static
|
||||||
|
test-results
|
||||||
|
debug-storybook.log
|
||||||
11
src/Website/.prettierrc.json
Normal file
11
src/Website/.prettierrc.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"semi": true,
|
||||||
|
"tabWidth": 3,
|
||||||
|
"useTabs": false,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ type Pages = {
|
|||||||
"/": {
|
"/": {
|
||||||
params: {};
|
params: {};
|
||||||
};
|
};
|
||||||
|
"/theme": {
|
||||||
|
params: {};
|
||||||
|
};
|
||||||
"/login": {
|
"/login": {
|
||||||
params: {};
|
params: {};
|
||||||
};
|
};
|
||||||
@@ -43,12 +46,16 @@ type Pages = {
|
|||||||
type RouteFiles = {
|
type RouteFiles = {
|
||||||
"root.tsx": {
|
"root.tsx": {
|
||||||
id: "root";
|
id: "root";
|
||||||
page: "/" | "/login" | "/register" | "/logout" | "/dashboard" | "/confirm" | "/beers" | "/breweries" | "/beer-styles";
|
page: "/" | "/theme" | "/login" | "/register" | "/logout" | "/dashboard" | "/confirm" | "/beers" | "/breweries" | "/beer-styles";
|
||||||
};
|
};
|
||||||
"routes/home.tsx": {
|
"routes/home.tsx": {
|
||||||
id: "routes/home";
|
id: "routes/home";
|
||||||
page: "/";
|
page: "/";
|
||||||
};
|
};
|
||||||
|
"routes/theme.tsx": {
|
||||||
|
id: "routes/theme";
|
||||||
|
page: "/theme";
|
||||||
|
};
|
||||||
"routes/login.tsx": {
|
"routes/login.tsx": {
|
||||||
id: "routes/login";
|
id: "routes/login";
|
||||||
page: "/login";
|
page: "/login";
|
||||||
@@ -86,6 +93,7 @@ type RouteFiles = {
|
|||||||
type RouteModules = {
|
type RouteModules = {
|
||||||
"root": typeof import("./app/root.tsx");
|
"root": typeof import("./app/root.tsx");
|
||||||
"routes/home": typeof import("./app/routes/home.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/login": typeof import("./app/routes/login.tsx");
|
||||||
"routes/register": typeof import("./app/routes/register.tsx");
|
"routes/register": typeof import("./app/routes/register.tsx");
|
||||||
"routes/logout": typeof import("./app/routes/logout.tsx");
|
"routes/logout": typeof import("./app/routes/logout.tsx");
|
||||||
|
|||||||
62
src/Website/.react-router/types/app/routes/+types/theme.ts
Normal file
62
src/Website/.react-router/types/app/routes/+types/theme.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// 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<Info & { module: Module, matches: Matches }, false>;
|
||||||
|
|
||||||
|
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"];
|
||||||
|
}
|
||||||
50
src/Website/.storybook/main.ts
Normal file
50
src/Website/.storybook/main.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginName = typeof plugin === 'object' && 'name' in plugin ? plugin.name : '';
|
||||||
|
return !pluginName.startsWith('react-router');
|
||||||
|
});
|
||||||
|
|
||||||
|
config.build ??= {};
|
||||||
|
config.build.rollupOptions ??= {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(warning);
|
||||||
|
};
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
6
src/Website/.storybook/preview-head.html
Normal file
6
src/Website/.storybook/preview-head.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||||
|
<link
|
||||||
|
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"
|
||||||
|
/>
|
||||||
63
src/Website/.storybook/preview.ts
Normal file
63
src/Website/.storybook/preview.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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',
|
||||||
|
|
||||||
|
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;
|
||||||
7
src/Website/.storybook/vitest.setup.ts
Normal file
7
src/Website/.storybook/vitest.setup.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
|
||||||
|
import { setProjectAnnotations } from '@storybook/react-vite';
|
||||||
|
import * as projectAnnotations from './preview';
|
||||||
|
|
||||||
|
// This is an important step to apply the right configuration when testing your stories.
|
||||||
|
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||||
|
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"hash": "fdf55a9c",
|
|
||||||
"configHash": "c6a852b3",
|
|
||||||
"lockfileHash": "e3b0c442",
|
|
||||||
"browserHash": "58d74d30",
|
|
||||||
"optimized": {},
|
|
||||||
"chunks": {}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "module"
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
@plugin "daisyui" {
|
@plugin "daisyui" {
|
||||||
themes: biergarten-lager, biergarten-stout;
|
themes: biergarten-lager, biergarten-stout, biergarten-cassis, biergarten-weizen;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans:
|
--font-sans:
|
||||||
"DM Sans", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
|
'DM Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||||
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
--font-serif: "Volkhov", ui-serif, Georgia, serif;
|
--font-serif: 'Volkhov', ui-serif, Georgia, serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@@ -17,102 +17,235 @@ h4,
|
|||||||
h5,
|
h5,
|
||||||
h6,
|
h6,
|
||||||
.card-title {
|
.card-title {
|
||||||
font-family: var(--font-serif);
|
font-family: var(--font-serif);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────
|
||||||
|
BIERGARTEN LAGER
|
||||||
|
Light. Warm parchment base, mellow amber
|
||||||
|
primary, softened mahogany secondary.
|
||||||
|
───────────────────────────────────────── */
|
||||||
@plugin "daisyui/theme" {
|
@plugin "daisyui/theme" {
|
||||||
name: "biergarten-lager";
|
name: 'biergarten-lager';
|
||||||
default: true;
|
default: true;
|
||||||
prefersdark: false;
|
prefersdark: false;
|
||||||
color-scheme: "light";
|
color-scheme: 'light';
|
||||||
/* Base — warm parchment / aged paper */
|
|
||||||
--color-base-100: oklch(97% 0.025 80);
|
--color-base-100: oklch(96% 0.012 82); /* warm parchment */
|
||||||
--color-base-200: oklch(92% 0.04 78);
|
--color-base-200: oklch(92% 0.018 80); /* brushed paper */
|
||||||
--color-base-300: oklch(86% 0.06 75);
|
--color-base-300: oklch(87% 0.025 78); /* tinted linen */
|
||||||
--color-base-content: oklch(28% 0.05 55);
|
--color-base-content: oklch(28% 0.025 58); /* dark brown ink — 15.6:1 on base-100 */
|
||||||
/* Primary — golden amber lager */
|
|
||||||
--color-primary: oklch(68% 0.165 60);
|
--color-primary: oklch(65% 0.085 62); /* mellow amber */
|
||||||
--color-primary-content: oklch(18% 0.04 55);
|
--color-primary-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */
|
||||||
/* Secondary — deep mahogany ale */
|
|
||||||
--color-secondary: oklch(38% 0.09 40);
|
--color-secondary: oklch(42% 0.05 42); /* softened mahogany */
|
||||||
--color-secondary-content: oklch(95% 0.02 75);
|
--color-secondary-content: oklch(96% 0.01 76); /* off-white — 14.2:1 on secondary */
|
||||||
/* Accent — frothy cream head */
|
|
||||||
--color-accent: oklch(94% 0.03 90);
|
--color-accent: oklch(93% 0.015 90); /* frothy cream */
|
||||||
--color-accent-content: oklch(30% 0.05 55);
|
--color-accent-content: oklch(28% 0.025 58); /* dark brown — 12.8:1 on accent */
|
||||||
/* Neutral — roasted stout */
|
|
||||||
--color-neutral: oklch(24% 0.04 45);
|
--color-neutral: oklch(28% 0.02 46); /* warm roast dark */
|
||||||
--color-neutral-content: oklch(92% 0.025 80);
|
--color-neutral-content: oklch(92% 0.012 80); /* pale parchment — 12.0:1 on neutral */
|
||||||
/* Info — cool hop green */
|
|
||||||
--color-info: oklch(58% 0.14 145);
|
--color-info: oklch(46% 0.065 145); /* muted hop green */
|
||||||
--color-info-content: oklch(97% 0.015 145);
|
--color-info-content: oklch(97% 0.008 145); /* near-white — 14.2:1 on info */
|
||||||
/* Success — fresh barley */
|
|
||||||
--color-success: oklch(72% 0.13 120);
|
--color-success: oklch(70% 0.06 122); /* soft barley gold */
|
||||||
--color-success-content: oklch(20% 0.04 120);
|
--color-success-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */
|
||||||
/* Warning — amber harvest */
|
|
||||||
--color-warning: oklch(74% 0.19 55);
|
--color-warning: oklch(72% 0.09 56); /* toned amber */
|
||||||
--color-warning-content: oklch(18% 0.04 55);
|
--color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */
|
||||||
/* Error — deep cherry kriek */
|
|
||||||
--color-error: oklch(52% 0.2 20);
|
--color-error: oklch(54% 0.09 22); /* restrained cherry */
|
||||||
--color-error-content: oklch(97% 0.012 15);
|
--color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */
|
||||||
--radius-selector: 0.375rem;
|
|
||||||
--radius-field: 0.5rem;
|
--color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */
|
||||||
--radius-box: 0.875rem;
|
--color-surface-content: oklch(28% 0.025 58); /* dark brown — 9.2:1 on surface */
|
||||||
--size-selector: 0.25rem;
|
|
||||||
--size-field: 0.25rem;
|
--color-muted: oklch(42% 0.055 62); /* amber-brown — 14.2:1 on base-100, 8.3:1 on surface */
|
||||||
--border: 1px;
|
--color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */
|
||||||
--depth: 1;
|
--color-highlight-content: oklch(22% 0.025 55); /* dark brown — 4.9:1 on highlight */
|
||||||
--noise: 1;
|
|
||||||
|
--radius-selector: 0.375rem;
|
||||||
|
--radius-field: 0.5rem;
|
||||||
|
--radius-box: 0.875rem;
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
--border: 1px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────
|
||||||
|
BIERGARTEN STOUT
|
||||||
|
Dark. Charred barrel base, golden amber
|
||||||
|
primary, deep mahogany secondary.
|
||||||
|
───────────────────────────────────────── */
|
||||||
@plugin "daisyui/theme" {
|
@plugin "daisyui/theme" {
|
||||||
name: "biergarten-stout";
|
name: 'biergarten-stout';
|
||||||
default: false;
|
default: false;
|
||||||
prefersdark: true;
|
prefersdark: true;
|
||||||
color-scheme: "dark";
|
color-scheme: 'dark';
|
||||||
|
|
||||||
/* Base — charred barrel / roasted malt darkness */
|
--color-base-100: oklch(14% 0.006 45); /* charred barrel black */
|
||||||
--color-base-100: oklch(14% 0.006 45);
|
--color-base-200: oklch(18% 0.008 43); /* roasted malt dark */
|
||||||
--color-base-200: oklch(18% 0.008 43);
|
--color-base-300: oklch(23% 0.01 42); /* deep brown */
|
||||||
--color-base-300: oklch(23% 0.01 42);
|
--color-base-content: oklch(88% 0.008 75); /* warm off-white — 9.4:1 on base-100 */
|
||||||
--color-base-content: oklch(88% 0.008 75);
|
|
||||||
|
|
||||||
/* Primary — golden amber lager */
|
--color-primary: oklch(68% 0.055 60); /* golden amber */
|
||||||
--color-primary: oklch(68% 0.055 60);
|
--color-primary-content: oklch(92% 0.012 50); /* warm off-white — 4.6:1 on primary */
|
||||||
--color-primary-content: oklch(14% 0.012 50);
|
|
||||||
|
|
||||||
/* Secondary — deep mahogany ale */
|
--color-secondary: oklch(48% 0.035 40); /* deep mahogany ale */
|
||||||
--color-secondary: oklch(55% 0.025 40);
|
--color-secondary-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */
|
||||||
--color-secondary-content: oklch(97% 0.005 75);
|
|
||||||
|
|
||||||
/* Accent — frothy cream head */
|
--color-accent: oklch(82% 0.01 88); /* frothy cream head */
|
||||||
--color-accent: oklch(82% 0.01 88);
|
--color-accent-content: oklch(18% 0.012 55); /* near-black — 6.2:1 on accent */
|
||||||
--color-accent-content: oklch(20% 0.01 55);
|
|
||||||
|
|
||||||
/* Neutral — near-black with warmth */
|
--color-neutral: oklch(20% 0.008 45); /* near-black with warmth */
|
||||||
--color-neutral: oklch(20% 0.008 45);
|
--color-neutral-content: oklch(88% 0.007 78); /* warm off-white — 9.3:1 on neutral */
|
||||||
--color-neutral-content: oklch(88% 0.007 78);
|
|
||||||
|
|
||||||
/* Info — cool hop green */
|
--color-info: oklch(60% 0.04 145); /* cool hop green */
|
||||||
--color-info: oklch(54% 0.04 145);
|
--color-info-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */
|
||||||
--color-info-content: oklch(97% 0.005 145);
|
|
||||||
|
|
||||||
/* Success — fresh barley */
|
--color-success: oklch(66% 0.038 120); /* fresh barley */
|
||||||
--color-success: oklch(66% 0.038 120);
|
--color-success-content: oklch(90% 0.012 120); /* pale barley-white — 4.6:1 on success */
|
||||||
--color-success-content: oklch(14% 0.012 120);
|
|
||||||
|
|
||||||
/* Warning — amber harvest */
|
--color-warning: oklch(70% 0.055 55); /* amber harvest */
|
||||||
--color-warning: oklch(70% 0.055 55);
|
--color-warning-content: oklch(94% 0.012 55); /* warm near-white — 4.7:1 on warning */
|
||||||
--color-warning-content: oklch(14% 0.012 55);
|
|
||||||
|
|
||||||
/* Error — deep cherry kriek */
|
--color-error: oklch(50% 0.06 20); /* deep cherry kriek */
|
||||||
--color-error: oklch(50% 0.06 20);
|
--color-error-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */
|
||||||
--color-error-content: oklch(97% 0.004 15);
|
|
||||||
|
|
||||||
--radius-selector: 0.375rem;
|
--color-surface: oklch(26% 0.012 45); /* elevated dark panel */
|
||||||
--radius-field: 0.5rem;
|
--color-surface-content: oklch(88% 0.008 75); /* warm off-white — 9.2:1 on surface */
|
||||||
--radius-box: 0.875rem;
|
|
||||||
--size-selector: 0.25rem;
|
--color-muted: oklch(78% 0.018 72); /* warm grey — 4.7:1 on base-100, 4.6:1 on surface */
|
||||||
--size-field: 0.25rem;
|
--color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */
|
||||||
--border: 1px;
|
--color-highlight-content: oklch(88% 0.008 75); /* warm off-white — 9.0:1 on highlight */
|
||||||
--depth: 1;
|
|
||||||
--noise: 1;
|
--radius-selector: 0.375rem;
|
||||||
|
--radius-field: 0.5rem;
|
||||||
|
--radius-box: 0.875rem;
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
--border: 1px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────
|
||||||
|
BIERGARTEN CASSIS
|
||||||
|
Dark. Blackberry base, cassis berry
|
||||||
|
primary, sour cherry secondary.
|
||||||
|
───────────────────────────────────────── */
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
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-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-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-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-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-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 */
|
||||||
|
|
||||||
|
--radius-selector: 0.5rem;
|
||||||
|
--radius-field: 0.5rem;
|
||||||
|
--radius-box: 1rem;
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
--border: 1px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────
|
||||||
|
BIERGARTEN WEIZEN
|
||||||
|
Light. Near-white barley-green base,
|
||||||
|
fresh-cut barley primary, sage secondary.
|
||||||
|
───────────────────────────────────────── */
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
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-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-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-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-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-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 */
|
||||||
|
|
||||||
|
--radius-selector: 2rem;
|
||||||
|
--radius-field: 2rem;
|
||||||
|
--radius-box: 1rem;
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
--border: 1px;
|
||||||
|
--depth: 0;
|
||||||
|
--noise: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +1,144 @@
|
|||||||
import { Link } from "react-router";
|
import {
|
||||||
|
Disclosure,
|
||||||
|
DisclosureButton,
|
||||||
|
DisclosurePanel,
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
MenuItem,
|
||||||
|
MenuItems,
|
||||||
|
} from '@headlessui/react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
auth: {
|
auth: {
|
||||||
username: string;
|
username: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
userAccountId: string;
|
userAccountId: string;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Navbar({ auth }: NavbarProps) {
|
export default function Navbar({ auth }: NavbarProps) {
|
||||||
return (
|
const navLinks = [
|
||||||
<div className="navbar bg-base-100 shadow-md sticky top-0 z-50">
|
{ to: '/theme', label: 'Theme' },
|
||||||
<div className="navbar-start">
|
{ to: '/beers', label: 'Beers' },
|
||||||
<Link to="/" className="text-xl font-bold px-4">
|
{ to: '/breweries', label: 'Breweries' },
|
||||||
🍺 The Biergarten App
|
{ to: '/beer-styles', label: 'Beer Styles' },
|
||||||
</Link>
|
];
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="navbar-center gap-4">
|
return (
|
||||||
<Link to="/beers" className="btn btn-ghost btn-sm">
|
<Disclosure
|
||||||
Beers
|
as="nav"
|
||||||
</Link>
|
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
|
||||||
<Link to="/breweries" className="btn btn-ghost btn-sm">
|
>
|
||||||
Breweries
|
{({ open }) => (
|
||||||
</Link>
|
<>
|
||||||
<Link to="/beer-styles" className="btn btn-ghost btn-sm">
|
<div className="navbar mx-auto max-w-7xl px-2 sm:px-4">
|
||||||
Beer Styles
|
<div className="navbar-start gap-2">
|
||||||
</Link>
|
<DisclosureButton
|
||||||
</div>
|
className="btn btn-ghost btn-square lg:hidden"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="h-5 w-5 stroke-current"
|
||||||
|
>
|
||||||
|
{open ? (
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</DisclosureButton>
|
||||||
|
|
||||||
<div className="navbar-end gap-2 pr-4">
|
<Link to="/" className="text-xl font-bold">
|
||||||
<Link to="/register" className="btn btn-ghost btn-sm">
|
🍺 The Biergarten App
|
||||||
Register User
|
</Link>
|
||||||
</Link>
|
</div>
|
||||||
|
|
||||||
{auth ? (
|
<div className="navbar-center hidden lg:flex gap-2">
|
||||||
<>
|
{navLinks.map((link) => (
|
||||||
<Link to="/dashboard" className="btn btn-primary btn-sm">
|
<Link key={link.to} to={link.to} className="btn btn-ghost btn-sm">
|
||||||
Dashboard
|
{link.label}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="divider divider-horizontal m-0 h-6"></div>
|
))}
|
||||||
<span className="text-sm text-base-content/70">
|
</div>
|
||||||
{auth.username}
|
|
||||||
</span>
|
<div className="navbar-end gap-2">
|
||||||
<Link to="/logout" className="btn btn-ghost btn-sm">
|
{!auth && (
|
||||||
Logout
|
<Link to="/register" className="btn btn-ghost btn-sm hidden sm:inline-flex">
|
||||||
</Link>
|
Register User
|
||||||
</>
|
</Link>
|
||||||
) : (
|
)}
|
||||||
<Link to="/login" className="btn btn-primary btn-sm">
|
|
||||||
Login
|
{auth ? (
|
||||||
</Link>
|
<>
|
||||||
)}
|
<Link to="/dashboard" className="btn btn-primary btn-sm">
|
||||||
</div>
|
Dashboard
|
||||||
</div>
|
</Link>
|
||||||
);
|
|
||||||
|
<Menu as="div" className="relative">
|
||||||
|
<MenuButton className="btn btn-ghost btn-sm">
|
||||||
|
{auth.username}
|
||||||
|
</MenuButton>
|
||||||
|
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none">
|
||||||
|
<MenuItem>
|
||||||
|
{({ focus }) => (
|
||||||
|
<Link to="/dashboard" className={focus ? 'active' : ''}>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem>
|
||||||
|
{({ focus }) => (
|
||||||
|
<Link to="/logout" className={focus ? 'active' : ''}>
|
||||||
|
Logout
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
</MenuItems>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link to="/login" className="btn btn-primary btn-sm">
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{navLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.to}
|
||||||
|
to={link.to}
|
||||||
|
className="btn btn-ghost btn-sm justify-start"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{!auth && (
|
||||||
|
<Link to="/register" className="btn btn-ghost btn-sm justify-start">
|
||||||
|
Register User
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DisclosurePanel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Disclosure>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/Website/app/components/forms/FormField.tsx
Normal file
40
src/Website/app/components/forms/FormField.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Description, Field, Label } from '@headlessui/react';
|
||||||
|
|
||||||
|
type FormFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
labelClassName?: string;
|
||||||
|
inputClassName?: string;
|
||||||
|
hintClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FormField({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
hint,
|
||||||
|
className,
|
||||||
|
labelClassName,
|
||||||
|
inputClassName,
|
||||||
|
hintClassName,
|
||||||
|
...inputProps
|
||||||
|
}: FormFieldProps) {
|
||||||
|
return (
|
||||||
|
<Field className={className ?? 'space-y-1'}>
|
||||||
|
<Label htmlFor={inputProps.id} className={labelClassName ?? 'label font-medium'}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
{...inputProps}
|
||||||
|
className={inputClassName ?? `input w-full ${error ? 'input-error' : ''}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Description className={hintClassName ?? 'label text-error'}>{error}</Description>
|
||||||
|
) : hint ? (
|
||||||
|
<Description className={hintClassName ?? 'label'}>{hint}</Description>
|
||||||
|
) : null}
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/Website/app/components/forms/SubmitButton.tsx
Normal file
31
src/Website/app/components/forms/SubmitButton.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Button } from '@headlessui/react';
|
||||||
|
|
||||||
|
interface SubmitButtonProps {
|
||||||
|
isSubmitting: boolean;
|
||||||
|
idleText: string;
|
||||||
|
submittingText: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubmitButton({
|
||||||
|
isSubmitting,
|
||||||
|
idleText,
|
||||||
|
submittingText,
|
||||||
|
className,
|
||||||
|
}: SubmitButtonProps) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={className ?? 'btn btn-primary w-full mt-2'}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-sm" /> {submittingText}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
idleText
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/Website/app/components/toast/ToastProvider.tsx
Normal file
25
src/Website/app/components/toast/ToastProvider.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
|
export default function ToastProvider() {
|
||||||
|
return (
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 3500,
|
||||||
|
className: 'rounded-box border border-base-300 bg-base-100 text-base-content shadow-lg',
|
||||||
|
success: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: 'var(--color-success)',
|
||||||
|
secondary: 'var(--color-success-content)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: 'var(--color-error)',
|
||||||
|
secondary: 'var(--color-error-content)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/Website/app/components/toast/toast.ts
Normal file
6
src/Website/app/components/toast/toast.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export const showSuccessToast = (message: string) => toast.success(message);
|
||||||
|
export const showErrorToast = (message: string) => toast.error(message);
|
||||||
|
export const showInfoToast = (message: string) => toast(message);
|
||||||
|
export const dismissToasts = () => toast.dismiss();
|
||||||
@@ -1,175 +1,162 @@
|
|||||||
import { createCookieSessionStorage, redirect } from "react-router";
|
import { createCookieSessionStorage, redirect } from 'react-router';
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:8080";
|
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
export interface AuthTokens {
|
export interface AuthTokens {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
userAccountId: string;
|
userAccountId: string;
|
||||||
username: string;
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiResponse<T> {
|
interface ApiResponse<T> {
|
||||||
message: string;
|
message: string;
|
||||||
payload: T;
|
payload: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginPayload {
|
interface LoginPayload {
|
||||||
userAccountId: string;
|
userAccountId: string;
|
||||||
username: string;
|
username: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RegistrationPayload extends LoginPayload {
|
interface RegistrationPayload extends LoginPayload {
|
||||||
confirmationEmailSent: boolean;
|
confirmationEmailSent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionStorage = createCookieSessionStorage({
|
const sessionStorage = createCookieSessionStorage({
|
||||||
cookie: {
|
cookie: {
|
||||||
name: "__session",
|
name: '__session',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
maxAge: 60 * 60 * 24 * 21, // 21 days (matches refresh token)
|
maxAge: 60 * 60 * 24 * 21, // 21 days (matches refresh token)
|
||||||
path: "/",
|
path: '/',
|
||||||
sameSite: "lax",
|
sameSite: 'lax',
|
||||||
secrets: [process.env.SESSION_SECRET || "dev-secret-change-me"],
|
secrets: [process.env.SESSION_SECRET || 'dev-secret-change-me'],
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === 'production',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function getSession(request: Request) {
|
export async function getSession(request: Request) {
|
||||||
return sessionStorage.getSession(request.headers.get("Cookie"));
|
return sessionStorage.getSession(request.headers.get('Cookie'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function commitSession(
|
export async function commitSession(session: Awaited<ReturnType<typeof getSession>>) {
|
||||||
session: Awaited<ReturnType<typeof getSession>>,
|
return sessionStorage.commitSession(session);
|
||||||
) {
|
|
||||||
return sessionStorage.commitSession(session);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function destroySession(
|
export async function destroySession(session: Awaited<ReturnType<typeof getSession>>) {
|
||||||
session: Awaited<ReturnType<typeof getSession>>,
|
return sessionStorage.destroySession(session);
|
||||||
) {
|
|
||||||
return sessionStorage.destroySession(session);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireAuth(request: Request): Promise<AuthTokens> {
|
export async function requireAuth(request: Request): Promise<AuthTokens> {
|
||||||
const session = await getSession(request);
|
const session = await getSession(request);
|
||||||
const accessToken = session.get("accessToken");
|
const accessToken = session.get('accessToken');
|
||||||
const refreshToken = session.get("refreshToken");
|
const refreshToken = session.get('refreshToken');
|
||||||
|
|
||||||
if (!accessToken || !refreshToken) {
|
if (!accessToken || !refreshToken) {
|
||||||
throw redirect("/login");
|
throw redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
userAccountId: session.get("userAccountId"),
|
userAccountId: session.get('userAccountId'),
|
||||||
username: session.get("username"),
|
username: session.get('username'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOptionalAuth(
|
export async function getOptionalAuth(request: Request): Promise<AuthTokens | null> {
|
||||||
request: Request,
|
const session = await getSession(request);
|
||||||
): Promise<AuthTokens | null> {
|
const accessToken = session.get('accessToken');
|
||||||
const session = await getSession(request);
|
|
||||||
const accessToken = session.get("accessToken");
|
|
||||||
|
|
||||||
if (!accessToken) return null;
|
if (!accessToken) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken: session.get("refreshToken"),
|
refreshToken: session.get('refreshToken'),
|
||||||
userAccountId: session.get("userAccountId"),
|
userAccountId: session.get('userAccountId'),
|
||||||
username: session.get("username"),
|
username: session.get('username'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(username: string, password: string) {
|
export async function login(username: string, password: string) {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
throw new Error(text || `Login failed (${res.status})`);
|
throw new Error(text || `Login failed (${res.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: ApiResponse<LoginPayload> = await res.json();
|
const data: ApiResponse<LoginPayload> = await res.json();
|
||||||
return data.payload;
|
return data.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function register(body: {
|
export async function register(body: {
|
||||||
username: string;
|
username: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
dateOfBirth: string;
|
dateOfBirth: string;
|
||||||
password: string;
|
password: string;
|
||||||
}) {
|
}) {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
throw new Error(text || `Registration failed (${res.status})`);
|
throw new Error(text || `Registration failed (${res.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: ApiResponse<RegistrationPayload> = await res.json();
|
const data: ApiResponse<RegistrationPayload> = await res.json();
|
||||||
return data.payload;
|
return data.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshTokens(refreshToken: string) {
|
export async function refreshTokens(refreshToken: string) {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/refresh`, {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ refreshToken }),
|
body: JSON.stringify({ refreshToken }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error("Token refresh failed");
|
throw new Error('Token refresh failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: ApiResponse<LoginPayload> = await res.json();
|
const data: ApiResponse<LoginPayload> = await res.json();
|
||||||
return data.payload;
|
return data.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmEmail(token: string, accessToken: string) {
|
export async function confirmEmail(token: string, accessToken: string) {
|
||||||
const res = await fetch(
|
const res = await fetch(`${API_BASE_URL}/api/auth/confirm?token=${encodeURIComponent(token)}`, {
|
||||||
`${API_BASE_URL}/api/auth/confirm?token=${encodeURIComponent(token)}`,
|
method: 'POST',
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
throw new Error(text || `Confirmation failed (${res.status})`);
|
throw new Error(text || `Confirmation failed (${res.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: ApiResponse<{ userAccountId: string; confirmedDate: string }> =
|
const data: ApiResponse<{ userAccountId: string; confirmedDate: string }> = await res.json();
|
||||||
await res.json();
|
return data.payload;
|
||||||
return data.payload;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createAuthSession(
|
export async function createAuthSession(payload: LoginPayload, redirectTo: string) {
|
||||||
payload: LoginPayload,
|
const session = await sessionStorage.getSession();
|
||||||
redirectTo: string,
|
session.set('accessToken', payload.accessToken);
|
||||||
) {
|
session.set('refreshToken', payload.refreshToken);
|
||||||
const session = await sessionStorage.getSession();
|
session.set('userAccountId', payload.userAccountId);
|
||||||
session.set("accessToken", payload.accessToken);
|
session.set('username', payload.username);
|
||||||
session.set("refreshToken", payload.refreshToken);
|
|
||||||
session.set("userAccountId", payload.userAccountId);
|
|
||||||
session.set("username", payload.username);
|
|
||||||
|
|
||||||
return redirect(redirectTo, {
|
return redirect(redirectTo, {
|
||||||
headers: { "Set-Cookie": await commitSession(session) },
|
headers: { 'Set-Cookie': await commitSession(session) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
import { z } from "zod";
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const loginSchema = z.object({
|
export const loginSchema = z.object({
|
||||||
username: z.string().min(1, "Username is required"),
|
username: z.string().min(1, 'Username is required'),
|
||||||
password: z.string().min(1, "Password is required"),
|
password: z.string().min(1, 'Password is required'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type LoginSchema = z.infer<typeof loginSchema>;
|
export type LoginSchema = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
export const registerSchema = z
|
export const registerSchema = z
|
||||||
.object({
|
.object({
|
||||||
username: z
|
username: z
|
||||||
.string()
|
.string()
|
||||||
.min(3, "Username must be at least 3 characters")
|
.min(3, 'Username must be at least 3 characters')
|
||||||
.max(20, "Username must be at most 20 characters"),
|
.max(20, 'Username must be at most 20 characters'),
|
||||||
firstName: z.string().min(1, "First name is required"),
|
firstName: z.string().min(1, 'First name is required'),
|
||||||
lastName: z.string().min(1, "Last name is required"),
|
lastName: z.string().min(1, 'Last name is required'),
|
||||||
email: z.string().email("Invalid email address"),
|
email: z.string().email('Invalid email address'),
|
||||||
dateOfBirth: z.string().min(1, "Date of birth is required"),
|
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||||
password: z
|
password: z
|
||||||
.string()
|
.string()
|
||||||
.min(8, "Password must be at least 8 characters")
|
.min(8, 'Password must be at least 8 characters')
|
||||||
.regex(/[A-Z]/, "Password must contain an uppercase letter")
|
.regex(/[A-Z]/, 'Password must contain an uppercase letter')
|
||||||
.regex(/[a-z]/, "Password must contain a lowercase letter")
|
.regex(/[a-z]/, 'Password must contain a lowercase letter')
|
||||||
.regex(/[0-9]/, "Password must contain a number"),
|
.regex(/[0-9]/, 'Password must contain a number'),
|
||||||
confirmPassword: z.string().min(1, "Please confirm your password"),
|
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
message: "Passwords must match",
|
message: 'Passwords must match',
|
||||||
path: ["confirmPassword"],
|
path: ['confirmPassword'],
|
||||||
});
|
});
|
||||||
|
|
||||||
export type RegisterSchema = z.infer<typeof registerSchema>;
|
export type RegisterSchema = z.infer<typeof registerSchema>;
|
||||||
|
|||||||
41
src/Website/app/lib/themes.ts
Normal file
41
src/Website/app/lib/themes.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export type ThemeName =
|
||||||
|
| 'biergarten-lager'
|
||||||
|
| 'biergarten-stout'
|
||||||
|
| 'biergarten-cassis'
|
||||||
|
| 'biergarten-weizen';
|
||||||
|
|
||||||
|
export interface ThemeOption {
|
||||||
|
value: ThemeName;
|
||||||
|
label: string;
|
||||||
|
vibe: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultThemeName: ThemeName = 'biergarten-lager';
|
||||||
|
export const themeStorageKey = 'biergarten-theme';
|
||||||
|
|
||||||
|
export const biergartenThemes: ThemeOption[] = [
|
||||||
|
{
|
||||||
|
value: 'biergarten-lager',
|
||||||
|
label: 'Biergarten Lager',
|
||||||
|
vibe: 'Muted parchment, mellow amber, daytime beer garden',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'biergarten-stout',
|
||||||
|
label: 'Biergarten Stout',
|
||||||
|
vibe: 'Charred barrel, deep roast, cozy evening cellar',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'biergarten-cassis',
|
||||||
|
label: 'Biergarten Cassis',
|
||||||
|
vibe: 'Blackberry barrel, sour berry dark, vivid night market',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'biergarten-weizen',
|
||||||
|
label: 'Biergarten Weizen',
|
||||||
|
vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isBiergartenTheme(value: string | null | undefined): value is ThemeName {
|
||||||
|
return biergartenThemes.some((theme) => theme.value === value);
|
||||||
|
}
|
||||||
@@ -1,88 +1,90 @@
|
|||||||
import {
|
import {
|
||||||
isRouteErrorResponse,
|
isRouteErrorResponse,
|
||||||
Links,
|
Links,
|
||||||
Meta,
|
Meta,
|
||||||
Outlet,
|
Outlet,
|
||||||
Scripts,
|
Scripts,
|
||||||
ScrollRestoration,
|
ScrollRestoration,
|
||||||
} from "react-router";
|
} from 'react-router';
|
||||||
|
|
||||||
import type { Route } from "./+types/root";
|
import type { Route } from './+types/root';
|
||||||
import "./app.css";
|
import './app.css';
|
||||||
import Navbar from "./components/Navbar";
|
import Navbar from './components/Navbar';
|
||||||
import { getOptionalAuth } from "./lib/auth.server";
|
import ToastProvider from './components/toast/ToastProvider';
|
||||||
|
import { getOptionalAuth } from './lib/auth.server';
|
||||||
|
|
||||||
export const links: Route.LinksFunction = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||||
{
|
{
|
||||||
rel: "preconnect",
|
rel: 'preconnect',
|
||||||
href: "https://fonts.gstatic.com",
|
href: 'https://fonts.gstatic.com',
|
||||||
crossOrigin: "anonymous",
|
crossOrigin: 'anonymous',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rel: "stylesheet",
|
rel: 'stylesheet',
|
||||||
href: "https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..900;1,9..40,100..900&family=Volkhov:ital,wght@0,400;0,700;1,400;1,700&display=swap",
|
href: 'https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..900;1,9..40,100..900&family=Volkhov:ital,wght@0,400;0,700;1,400;1,700&display=swap',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const loader = async ({ request }: Route.LoaderArgs) => {
|
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||||
const auth = await getOptionalAuth(request);
|
const auth = await getOptionalAuth(request);
|
||||||
return { auth };
|
return { auth };
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{children}
|
{children}
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App({ loaderData }: Route.ComponentProps) {
|
export default function App({ loaderData }: Route.ComponentProps) {
|
||||||
const { auth } = loaderData;
|
const { auth } = loaderData;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar auth={auth} />
|
<Navbar auth={auth} />
|
||||||
<Outlet />
|
<ToastProvider />
|
||||||
</>
|
<Outlet />
|
||||||
);
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
let message = "Oops!";
|
let message = 'Oops!';
|
||||||
let details = "An unexpected error occurred.";
|
let details = 'An unexpected error occurred.';
|
||||||
let stack: string | undefined;
|
let stack: string | undefined;
|
||||||
|
|
||||||
if (isRouteErrorResponse(error)) {
|
if (isRouteErrorResponse(error)) {
|
||||||
message = error.status === 404 ? "404" : "Error";
|
message = error.status === 404 ? '404' : 'Error';
|
||||||
details =
|
details =
|
||||||
error.status === 404
|
error.status === 404
|
||||||
? "The requested page could not be found."
|
? 'The requested page could not be found.'
|
||||||
: error.statusText || details;
|
: error.statusText || details;
|
||||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||||
details = error.message;
|
details = error.message;
|
||||||
stack = error.stack;
|
stack = error.stack;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="pt-16 p-4 container mx-auto">
|
<main className="pt-16 p-4 container mx-auto">
|
||||||
<h1>{message}</h1>
|
<h1>{message}</h1>
|
||||||
<p>{details}</p>
|
<p>{details}</p>
|
||||||
{stack && (
|
{stack && (
|
||||||
<pre className="w-full p-4 overflow-x-auto">
|
<pre className="w-full p-4 overflow-x-auto">
|
||||||
<code>{stack}</code>
|
<code>{stack}</code>
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
import { type RouteConfig, index, route } from '@react-router/dev/routes';
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
index("routes/home.tsx"),
|
index('routes/home.tsx'),
|
||||||
route("login", "routes/login.tsx"),
|
route('theme', 'routes/theme.tsx'),
|
||||||
route("register", "routes/register.tsx"),
|
route('login', 'routes/login.tsx'),
|
||||||
route("logout", "routes/logout.tsx"),
|
route('register', 'routes/register.tsx'),
|
||||||
route("dashboard", "routes/dashboard.tsx"),
|
route('logout', 'routes/logout.tsx'),
|
||||||
route("confirm", "routes/confirm.tsx"),
|
route('dashboard', 'routes/dashboard.tsx'),
|
||||||
route("beers", "routes/beers.tsx"),
|
route('confirm', 'routes/confirm.tsx'),
|
||||||
route("breweries", "routes/breweries.tsx"),
|
route('beers', 'routes/beers.tsx'),
|
||||||
route("beer-styles", "routes/beer-styles.tsx"),
|
route('breweries', 'routes/breweries.tsx'),
|
||||||
|
route('beer-styles', 'routes/beer-styles.tsx'),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import type { Route } from "./+types/beer-styles";
|
import type { Route } from './+types/beer-styles';
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
return [{ title: "Beer Styles | The Biergarten App" }];
|
return [{ title: 'Beer Styles | The Biergarten App' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BeerStyles() {
|
export default function BeerStyles() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="container mx-auto p-4">
|
<div className="container mx-auto p-4">
|
||||||
<h1 className="text-4xl font-bold mb-4">Beer Styles</h1>
|
<h1 className="text-4xl font-bold mb-4">Beer Styles</h1>
|
||||||
<p className="text-base-content/70">
|
<p className="text-base-content/70">Learn about different beer styles.</p>
|
||||||
Learn about different beer styles.
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { Route } from "./+types/beers";
|
import type { Route } from './+types/beers';
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
return [{ title: "Beers | The Biergarten App" }];
|
return [{ title: 'Beers | The Biergarten App' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Beers() {
|
export default function Beers() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="container mx-auto p-4">
|
<div className="container mx-auto p-4">
|
||||||
<h1 className="text-4xl font-bold mb-4">Beers</h1>
|
<h1 className="text-4xl font-bold mb-4">Beers</h1>
|
||||||
<p className="text-base-content/70">Explore our collection of beers.</p>
|
<p className="text-base-content/70">Explore our collection of beers.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import type { Route } from "./+types/breweries";
|
import type { Route } from './+types/breweries';
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
return [{ title: "Breweries | The Biergarten App" }];
|
return [{ title: 'Breweries | The Biergarten App' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Breweries() {
|
export default function Breweries() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="container mx-auto p-4">
|
<div className="container mx-auto p-4">
|
||||||
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
|
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
|
||||||
<p className="text-base-content/70">Discover our partner breweries.</p>
|
<p className="text-base-content/70">Discover our partner breweries.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +1,91 @@
|
|||||||
import { Link } from "react-router";
|
import { useEffect } from 'react';
|
||||||
import { confirmEmail, requireAuth } from "../lib/auth.server";
|
import { Link } from 'react-router';
|
||||||
import type { Route } from "./+types/confirm";
|
import { showErrorToast, showSuccessToast } from '../components/toast/toast';
|
||||||
|
import { confirmEmail, requireAuth } from '../lib/auth.server';
|
||||||
|
import type { Route } from './+types/confirm';
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
return [{ title: "Confirm Email | The Biergarten App" }];
|
return [{ title: 'Confirm Email | The Biergarten App' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const auth = await requireAuth(request);
|
const auth = await requireAuth(request);
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const token = url.searchParams.get("token");
|
const token = url.searchParams.get('token');
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return { success: false as const, error: "Missing confirmation token." };
|
return { success: false as const, error: 'Missing confirmation token.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await confirmEmail(token, auth.accessToken);
|
const payload = await confirmEmail(token, auth.accessToken);
|
||||||
return {
|
return {
|
||||||
success: true as const,
|
success: true as const,
|
||||||
confirmedDate: payload.confirmedDate,
|
confirmedDate: payload.confirmedDate,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
success: false as const,
|
success: false as const,
|
||||||
error: err instanceof Error ? err.message : "Confirmation failed.",
|
error: err instanceof Error ? err.message : 'Confirmation failed.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Confirm({ loaderData }: Route.ComponentProps) {
|
export default function Confirm({ loaderData }: Route.ComponentProps) {
|
||||||
return (
|
useEffect(() => {
|
||||||
<div className="hero min-h-screen bg-base-200">
|
if (loaderData.success) {
|
||||||
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
showSuccessToast('Email confirmed successfully.');
|
||||||
<div className="card-body items-center text-center gap-4">
|
return;
|
||||||
{loaderData.success ? (
|
}
|
||||||
<>
|
|
||||||
<div className="text-success text-6xl">✓</div>
|
showErrorToast(loaderData.error);
|
||||||
<h1 className="card-title text-2xl">Email Confirmed!</h1>
|
}, [loaderData]);
|
||||||
<p className="text-base-content/70">
|
|
||||||
Your email address has been successfully verified.
|
return (
|
||||||
</p>
|
<div className="hero min-h-screen bg-base-200">
|
||||||
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
|
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
||||||
<span className="text-base-content/50 text-xs uppercase tracking-widest font-semibold">
|
<div className="card-body items-center text-center gap-4">
|
||||||
Confirmed at
|
{loaderData.success ? (
|
||||||
</span>
|
<>
|
||||||
<p className="font-mono mt-1">
|
<div className="text-success text-6xl">✓</div>
|
||||||
{new Date(loaderData.confirmedDate).toLocaleString()}
|
<h1 className="card-title text-2xl">Email Confirmed!</h1>
|
||||||
</p>
|
<p className="text-base-content/70">
|
||||||
</div>
|
Your email address has been successfully verified.
|
||||||
<div className="card-actions w-full pt-2">
|
</p>
|
||||||
<Link to="/dashboard" className="btn btn-primary w-full">
|
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
|
||||||
Go to Dashboard
|
<span className="text-base-content/50 text-xs uppercase tracking-widest font-semibold">
|
||||||
</Link>
|
Confirmed at
|
||||||
</div>
|
</span>
|
||||||
</>
|
<p className="font-mono mt-1">
|
||||||
) : (
|
{new Date(loaderData.confirmedDate).toLocaleString()}
|
||||||
<>
|
</p>
|
||||||
<div className="text-error text-6xl">✕</div>
|
</div>
|
||||||
<h1 className="card-title text-2xl">Confirmation Failed</h1>
|
<div className="card-actions w-full pt-2">
|
||||||
<div role="alert" className="alert alert-error alert-soft w-full">
|
<Link to="/dashboard" className="btn btn-primary w-full">
|
||||||
<span>{loaderData.error}</span>
|
Go to Dashboard
|
||||||
</div>
|
</Link>
|
||||||
<p className="text-base-content/70 text-sm">
|
</div>
|
||||||
The confirmation link may have expired (valid for 30 minutes) or
|
</>
|
||||||
already been used.
|
) : (
|
||||||
</p>
|
<>
|
||||||
<div className="card-actions w-full pt-2 flex-col gap-2">
|
<div className="text-error text-6xl">✕</div>
|
||||||
<Link to="/dashboard" className="btn btn-primary w-full">
|
<h1 className="card-title text-2xl">Confirmation Failed</h1>
|
||||||
Back to Dashboard
|
<div role="alert" className="alert alert-error alert-soft w-full">
|
||||||
</Link>
|
<span>{loaderData.error}</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
<p className="text-base-content/70 text-sm">
|
||||||
)}
|
The confirmation link may have expired (valid for 30 minutes) or already
|
||||||
</div>
|
been used.
|
||||||
|
</p>
|
||||||
|
<div className="card-actions w-full pt-2 flex-col gap-2">
|
||||||
|
<Link to="/dashboard" className="btn btn-primary w-full">
|
||||||
|
Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +1,105 @@
|
|||||||
import { requireAuth } from "../lib/auth.server";
|
import { requireAuth } from '../lib/auth.server';
|
||||||
import type { Route } from "./+types/dashboard";
|
import type { Route } from './+types/dashboard';
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
return [{ title: "Dashboard | The Biergarten App" }];
|
return [{ title: 'Dashboard | The Biergarten App' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const auth = await requireAuth(request);
|
const auth = await requireAuth(request);
|
||||||
return {
|
return {
|
||||||
username: auth.username,
|
username: auth.username,
|
||||||
userAccountId: auth.userAccountId,
|
userAccountId: auth.userAccountId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dashboard({ loaderData }: Route.ComponentProps) {
|
export default function Dashboard({ loaderData }: Route.ComponentProps) {
|
||||||
const { username, userAccountId } = loaderData;
|
const { username, userAccountId } = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
|
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
|
||||||
<div className="card bg-base-100 shadow">
|
<div className="card bg-base-100 shadow">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h2 className="card-title text-2xl">Welcome, {username}!</h2>
|
<h2 className="card-title text-2xl">Welcome, {username}!</h2>
|
||||||
<p className="text-base-content/70">
|
<p className="text-base-content/70">
|
||||||
You are successfully authenticated. This is a protected page that
|
You are successfully authenticated. This is a protected page that requires a
|
||||||
requires a valid session.
|
valid session.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="bg-base-200 rounded-box p-4 mt-2">
|
<div className="bg-base-200 rounded-box p-4 mt-2">
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
|
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
|
||||||
Session Info
|
Session Info
|
||||||
</p>
|
</p>
|
||||||
<div className="stats stats-vertical w-full">
|
<div className="stats stats-vertical w-full">
|
||||||
<div className="stat py-2">
|
<div className="stat py-2">
|
||||||
<div className="stat-title">Username</div>
|
<div className="stat-title">Username</div>
|
||||||
<div className="stat-value text-lg font-mono">{username}</div>
|
<div className="stat-value text-lg font-mono">{username}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat py-2">
|
<div className="stat py-2">
|
||||||
<div className="stat-title">User ID</div>
|
<div className="stat-title">User ID</div>
|
||||||
<div className="stat-desc font-mono text-xs mt-1">
|
<div className="stat-desc font-mono text-xs mt-1">{userAccountId}</div>
|
||||||
{userAccountId}
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card bg-base-100 shadow">
|
<div className="card bg-base-100 shadow">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h2 className="card-title">Auth Flow Demo</h2>
|
<h2 className="card-title">Auth Flow Demo</h2>
|
||||||
<p className="text-sm text-base-content/70">
|
<p className="text-sm text-base-content/70">
|
||||||
This demo showcases the following authentication features:
|
This demo showcases the following authentication features:
|
||||||
</p>
|
|
||||||
<ul className="list">
|
|
||||||
<li className="list-row">
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">Login</p>
|
|
||||||
<p className="text-sm text-base-content/60">
|
|
||||||
POST to <code className="kbd kbd-sm">/api/auth/login</code>{" "}
|
|
||||||
with username & password
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<ul className="list">
|
||||||
</li>
|
<li className="list-row">
|
||||||
<li className="list-row">
|
<div>
|
||||||
<div>
|
<p className="font-semibold">Login</p>
|
||||||
<p className="font-semibold">Register</p>
|
<p className="text-sm text-base-content/60">
|
||||||
<p className="text-sm text-base-content/60">
|
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
|
||||||
POST to{" "}
|
username & password
|
||||||
<code className="kbd kbd-sm">/api/auth/register</code> with
|
</p>
|
||||||
full user details
|
</div>
|
||||||
</p>
|
</li>
|
||||||
</div>
|
<li className="list-row">
|
||||||
</li>
|
<div>
|
||||||
<li className="list-row">
|
<p className="font-semibold">Register</p>
|
||||||
<div>
|
<p className="text-sm text-base-content/60">
|
||||||
<p className="font-semibold">Session</p>
|
POST to <code className="kbd kbd-sm">/api/auth/register</code> with
|
||||||
<p className="text-sm text-base-content/60">
|
full user details
|
||||||
JWT access & refresh tokens stored in an HTTP-only
|
</p>
|
||||||
cookie
|
</div>
|
||||||
</p>
|
</li>
|
||||||
</div>
|
<li className="list-row">
|
||||||
</li>
|
<div>
|
||||||
<li className="list-row">
|
<p className="font-semibold">Session</p>
|
||||||
<div>
|
<p className="text-sm text-base-content/60">
|
||||||
<p className="font-semibold">Protected Routes</p>
|
JWT access & refresh tokens stored in an HTTP-only cookie
|
||||||
<p className="text-sm text-base-content/60">
|
</p>
|
||||||
This dashboard requires authentication via{" "}
|
</div>
|
||||||
<code className="kbd kbd-sm">requireAuth()</code>
|
</li>
|
||||||
</p>
|
<li className="list-row">
|
||||||
</div>
|
<div>
|
||||||
</li>
|
<p className="font-semibold">Protected Routes</p>
|
||||||
<li className="list-row">
|
<p className="text-sm text-base-content/60">
|
||||||
<div>
|
This dashboard requires authentication via{' '}
|
||||||
<p className="font-semibold">Token Refresh</p>
|
<code className="kbd kbd-sm">requireAuth()</code>
|
||||||
<p className="text-sm text-base-content/60">
|
</p>
|
||||||
POST to{" "}
|
</div>
|
||||||
<code className="kbd kbd-sm">/api/auth/refresh</code> with
|
</li>
|
||||||
refresh token
|
<li className="list-row">
|
||||||
</p>
|
<div>
|
||||||
</div>
|
<p className="font-semibold">Token Refresh</p>
|
||||||
</li>
|
<p className="text-sm text-base-content/60">
|
||||||
</ul>
|
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with
|
||||||
</div>
|
refresh token
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,56 @@
|
|||||||
import { Link } from "react-router";
|
import { Link } from 'react-router';
|
||||||
import { getOptionalAuth } from "../lib/auth.server";
|
import { getOptionalAuth } from '../lib/auth.server';
|
||||||
import type { Route } from "./+types/home";
|
import type { Route } from './+types/home';
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
return [
|
return [
|
||||||
{ title: "The Biergarten App" },
|
{ title: 'The Biergarten App' },
|
||||||
{ name: "description", content: "Welcome to The Biergarten App" },
|
{ name: 'description', content: 'Welcome to The Biergarten App' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const auth = await getOptionalAuth(request);
|
const auth = await getOptionalAuth(request);
|
||||||
return { username: auth?.username ?? null };
|
return { username: auth?.username ?? null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home({ loaderData }: Route.ComponentProps) {
|
export default function Home({ loaderData }: Route.ComponentProps) {
|
||||||
const { username } = loaderData;
|
const { username } = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hero min-h-screen bg-base-200">
|
<div className="hero min-h-screen bg-base-200">
|
||||||
<div className="hero-content text-center">
|
<div className="hero-content text-center">
|
||||||
<div className="max-w-md space-y-6">
|
<div className="max-w-md space-y-6">
|
||||||
<h1 className="text-5xl font-bold">🍺 The Biergarten App</h1>
|
<h1 className="text-5xl font-bold">🍺 The Biergarten App</h1>
|
||||||
<p className="text-lg text-base-content/70">Authentication Demo</p>
|
<p className="text-lg text-base-content/70">Authentication Demo</p>
|
||||||
|
|
||||||
{username ? (
|
{username ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-base-content/80">
|
<p className="text-base-content/80">
|
||||||
Welcome back,{" "}
|
Welcome back, <span className="font-semibold text-primary">{username}</span>
|
||||||
<span className="font-semibold text-primary">{username}</span>!
|
!
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex gap-3 justify-center">
|
||||||
<Link to="/dashboard" className="btn btn-primary">
|
<Link to="/dashboard" className="btn btn-primary">
|
||||||
Dashboard
|
Dashboard
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/logout" className="btn btn-ghost">
|
<Link to="/logout" className="btn btn-ghost">
|
||||||
Logout
|
Logout
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex gap-3 justify-center">
|
||||||
<Link to="/login" className="btn btn-primary">
|
<Link to="/login" className="btn btn-primary">
|
||||||
Login
|
Login
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/register" className="btn btn-outline">
|
<Link to="/register" className="btn btn-outline">
|
||||||
Register
|
Register
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,133 +1,128 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from "react-hook-form";
|
import { HomeSimpleDoor, LogIn, UserPlus } from 'iconoir-react';
|
||||||
import { Link, redirect, useNavigation, useSubmit } from "react-router";
|
import { useEffect } from 'react';
|
||||||
import { createAuthSession, getOptionalAuth, login } from "../lib/auth.server";
|
import { useForm } from 'react-hook-form';
|
||||||
import { loginSchema, type LoginSchema } from "../lib/schemas";
|
import { Link, redirect, useNavigation, useSubmit } from 'react-router';
|
||||||
import type { Route } from "./+types/login";
|
import FormField from '../components/forms/FormField';
|
||||||
|
import SubmitButton from '../components/forms/SubmitButton';
|
||||||
|
import { showErrorToast } from '../components/toast/toast';
|
||||||
|
import { createAuthSession, getOptionalAuth, login } from '../lib/auth.server';
|
||||||
|
import { loginSchema, type LoginSchema } from '../lib/schemas';
|
||||||
|
import type { Route } from './+types/login';
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
return [{ title: "Login | The Biergarten App" }];
|
return [{ title: 'Login | The Biergarten App' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const auth = await getOptionalAuth(request);
|
const auth = await getOptionalAuth(request);
|
||||||
if (auth) throw redirect("/dashboard");
|
if (auth) throw redirect('/dashboard');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request }: Route.ActionArgs) {
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const result = loginSchema.safeParse({
|
const result = loginSchema.safeParse({
|
||||||
username: formData.get("username"),
|
username: formData.get('username'),
|
||||||
password: formData.get("password"),
|
password: formData.get('password'),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return { error: result.error.issues[0].message };
|
return { error: result.error.issues[0].message };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await login(result.data.username, result.data.password);
|
const payload = await login(result.data.username, result.data.password);
|
||||||
return createAuthSession(payload, "/dashboard");
|
return createAuthSession(payload, '/dashboard');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { error: err instanceof Error ? err.message : "Login failed." };
|
return { error: err instanceof Error ? err.message : 'Login failed.' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Login({ actionData }: Route.ComponentProps) {
|
export default function Login({ actionData }: Route.ComponentProps) {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const submit = useSubmit();
|
const submit = useSubmit();
|
||||||
const isSubmitting = navigation.state === "submitting";
|
const isSubmitting = navigation.state === 'submitting';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<LoginSchema>({ resolver: zodResolver(loginSchema) });
|
} = useForm<LoginSchema>({ resolver: zodResolver(loginSchema) });
|
||||||
|
|
||||||
const onSubmit = handleSubmit((data) => {
|
const onSubmit = handleSubmit((data) => {
|
||||||
submit(data, { method: "post" });
|
submit(data, { method: 'post' });
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<div className="hero min-h-screen bg-base-200">
|
if (actionData?.error) {
|
||||||
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
showErrorToast(actionData.error);
|
||||||
<div className="card-body gap-4">
|
}
|
||||||
<div className="text-center">
|
}, [actionData?.error]);
|
||||||
<h1 className="card-title text-3xl justify-center">Login</h1>
|
|
||||||
<p className="text-base-content/70">
|
|
||||||
Sign in to your Biergarten account
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{actionData?.error && (
|
return (
|
||||||
<div role="alert" className="alert alert-error alert-soft">
|
<div className="hero min-h-screen bg-base-200">
|
||||||
<span>{actionData.error}</span>
|
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
||||||
|
<div className="card-body gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="card-title text-3xl justify-center gap-2">
|
||||||
|
<LogIn className="size-7" aria-hidden="true" />
|
||||||
|
Login
|
||||||
|
</h1>
|
||||||
|
<p className="text-base-content/70">Sign in to your Biergarten account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionData?.error && (
|
||||||
|
<div role="alert" className="alert alert-error alert-soft">
|
||||||
|
<span>{actionData.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="space-y-3">
|
||||||
|
<FormField
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
placeholder="your_username"
|
||||||
|
label="Username"
|
||||||
|
error={errors.username?.message}
|
||||||
|
{...register('username')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
label="Password"
|
||||||
|
error={errors.password?.message}
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SubmitButton
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
idleText="Sign In"
|
||||||
|
submittingText="Signing in..."
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="divider text-xs">New here?</div>
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<Link to="/register" className="btn btn-outline btn-sm w-full gap-2">
|
||||||
|
<UserPlus className="size-4" aria-hidden="true" />
|
||||||
|
Create an account
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="link link-hover text-sm text-base-content/60 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<HomeSimpleDoor className="size-4" aria-hidden="true" />
|
||||||
|
Back to home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="space-y-3">
|
|
||||||
<fieldset className="fieldset">
|
|
||||||
<legend className="fieldset-legend">Username</legend>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
type="text"
|
|
||||||
autoComplete="username"
|
|
||||||
placeholder="your_username"
|
|
||||||
className={`input w-full ${errors.username ? "input-error" : ""}`}
|
|
||||||
{...register("username")}
|
|
||||||
/>
|
|
||||||
{errors.username && (
|
|
||||||
<p className="label text-error">{errors.username.message}</p>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset className="fieldset">
|
|
||||||
<legend className="fieldset-legend">Password</legend>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
className={`input w-full ${errors.password ? "input-error" : ""}`}
|
|
||||||
{...register("password")}
|
|
||||||
/>
|
|
||||||
{errors.password && (
|
|
||||||
<p className="label text-error">{errors.password.message}</p>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="btn btn-primary w-full mt-2"
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<span className="loading loading-spinner loading-sm" />{" "}
|
|
||||||
Signing in...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Sign In"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="divider text-xs">New here?</div>
|
|
||||||
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<Link to="/register" className="btn btn-outline btn-sm w-full">
|
|
||||||
Create an account
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="link link-hover text-sm text-base-content/60"
|
|
||||||
>
|
|
||||||
← Back to home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { redirect } from "react-router";
|
import { redirect } from 'react-router';
|
||||||
import { destroySession, getSession } from "../lib/auth.server";
|
import { destroySession, getSession } from '../lib/auth.server';
|
||||||
import type { Route } from "./+types/logout";
|
import type { Route } from './+types/logout';
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const session = await getSession(request);
|
const session = await getSession(request);
|
||||||
return redirect("/", {
|
return redirect('/', {
|
||||||
headers: { "Set-Cookie": await destroySession(session) },
|
headers: { 'Set-Cookie': await destroySession(session) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,231 +1,189 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from "react-hook-form";
|
import { useEffect } from 'react';
|
||||||
import { Link, redirect, useNavigation, useSubmit } from "react-router";
|
import { useForm } from 'react-hook-form';
|
||||||
import {
|
import { Link, redirect, useNavigation, useSubmit } from 'react-router';
|
||||||
createAuthSession,
|
import FormField from '../components/forms/FormField';
|
||||||
getOptionalAuth,
|
import SubmitButton from '../components/forms/SubmitButton';
|
||||||
register,
|
import { showErrorToast } from '../components/toast/toast';
|
||||||
} from "../lib/auth.server";
|
import { createAuthSession, getOptionalAuth, register } from '../lib/auth.server';
|
||||||
import { registerSchema, type RegisterSchema } from "../lib/schemas";
|
import { registerSchema, type RegisterSchema } from '../lib/schemas';
|
||||||
import type { Route } from "./+types/register";
|
import type { Route } from './+types/register';
|
||||||
|
|
||||||
export function meta({}: Route.MetaArgs) {
|
export function meta({}: Route.MetaArgs) {
|
||||||
return [{ title: "Register | The Biergarten App" }];
|
return [{ title: 'Register | The Biergarten App' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({ request }: Route.LoaderArgs) {
|
export async function loader({ request }: Route.LoaderArgs) {
|
||||||
const auth = await getOptionalAuth(request);
|
const auth = await getOptionalAuth(request);
|
||||||
if (auth) throw redirect("/dashboard");
|
if (auth) throw redirect('/dashboard');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request }: Route.ActionArgs) {
|
export async function action({ request }: Route.ActionArgs) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const result = registerSchema.safeParse({
|
const result = registerSchema.safeParse({
|
||||||
username: formData.get("username"),
|
username: formData.get('username'),
|
||||||
firstName: formData.get("firstName"),
|
firstName: formData.get('firstName'),
|
||||||
lastName: formData.get("lastName"),
|
lastName: formData.get('lastName'),
|
||||||
email: formData.get("email"),
|
email: formData.get('email'),
|
||||||
dateOfBirth: formData.get("dateOfBirth"),
|
dateOfBirth: formData.get('dateOfBirth'),
|
||||||
password: formData.get("password"),
|
password: formData.get('password'),
|
||||||
confirmPassword: formData.get("confirmPassword"),
|
confirmPassword: formData.get('confirmPassword'),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const fieldErrors = result.error.flatten().fieldErrors;
|
const fieldErrors = result.error.flatten().fieldErrors;
|
||||||
return { error: null, fieldErrors };
|
return { error: null, fieldErrors };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { confirmPassword: _, ...body } = result.data;
|
const body = {
|
||||||
const payload = await register(body);
|
username: result.data.username,
|
||||||
return createAuthSession(payload, "/dashboard");
|
firstName: result.data.firstName,
|
||||||
} catch (err) {
|
lastName: result.data.lastName,
|
||||||
return {
|
email: result.data.email,
|
||||||
error: err instanceof Error ? err.message : "Registration failed.",
|
dateOfBirth: result.data.dateOfBirth,
|
||||||
fieldErrors: null,
|
password: result.data.password,
|
||||||
};
|
};
|
||||||
}
|
const payload = await register(body);
|
||||||
|
return createAuthSession(payload, '/dashboard');
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
error: err instanceof Error ? err.message : 'Registration failed.',
|
||||||
|
fieldErrors: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Register({ actionData }: Route.ComponentProps) {
|
export default function Register({ actionData }: Route.ComponentProps) {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const submit = useSubmit();
|
const submit = useSubmit();
|
||||||
const isSubmitting = navigation.state === "submitting";
|
const isSubmitting = navigation.state === 'submitting';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register: field,
|
register: field,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
|
} = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
|
||||||
|
|
||||||
const onSubmit = handleSubmit((data) => {
|
const onSubmit = handleSubmit((data) => {
|
||||||
submit(data, { method: "post" });
|
submit(data, { method: 'post' });
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
|
if (actionData?.error) {
|
||||||
<div className="card w-full max-w-lg bg-base-100 shadow-xl">
|
showErrorToast(actionData.error);
|
||||||
<div className="card-body gap-4">
|
}
|
||||||
<div className="text-center">
|
}, [actionData?.error]);
|
||||||
<h1 className="card-title text-3xl justify-center">Register</h1>
|
|
||||||
<p className="text-base-content/70">
|
|
||||||
Create your Biergarten account
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{actionData?.error && (
|
return (
|
||||||
<div role="alert" className="alert alert-error alert-soft">
|
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
|
||||||
<span>{actionData.error}</span>
|
<div className="card w-full max-w-lg bg-base-100 shadow-xl">
|
||||||
|
<div className="card-body gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="card-title text-3xl justify-center">Register</h1>
|
||||||
|
<p className="text-base-content/70">Create your Biergarten account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionData?.error && (
|
||||||
|
<div role="alert" className="alert alert-error alert-soft">
|
||||||
|
<span>{actionData.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="space-y-3">
|
||||||
|
<FormField
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
placeholder="your_username"
|
||||||
|
label="Username"
|
||||||
|
hint="3-64 characters, alphanumeric and . _ -"
|
||||||
|
error={errors.username?.message}
|
||||||
|
{...field('username')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<FormField
|
||||||
|
id="firstName"
|
||||||
|
type="text"
|
||||||
|
autoComplete="given-name"
|
||||||
|
placeholder="Jane"
|
||||||
|
label="First Name"
|
||||||
|
error={errors.firstName?.message}
|
||||||
|
{...field('firstName')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="lastName"
|
||||||
|
type="text"
|
||||||
|
autoComplete="family-name"
|
||||||
|
placeholder="Doe"
|
||||||
|
label="Last Name"
|
||||||
|
error={errors.lastName?.message}
|
||||||
|
{...field('lastName')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="jane@example.com"
|
||||||
|
label="Email"
|
||||||
|
error={errors.email?.message}
|
||||||
|
{...field('email')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="dateOfBirth"
|
||||||
|
type="date"
|
||||||
|
label="Date of Birth"
|
||||||
|
hint="Must be 19 years or older"
|
||||||
|
error={errors.dateOfBirth?.message}
|
||||||
|
{...field('dateOfBirth')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
label="Password"
|
||||||
|
hint="8+ chars: uppercase, lowercase, digit, special character"
|
||||||
|
error={errors.password?.message}
|
||||||
|
{...field('password')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
label="Confirm Password"
|
||||||
|
error={errors.confirmPassword?.message}
|
||||||
|
{...field('confirmPassword')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SubmitButton
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
idleText="Create Account"
|
||||||
|
submittingText="Creating account..."
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="divider text-xs">Already have an account?</div>
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<Link to="/login" className="btn btn-outline btn-sm w-full">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link to="/" className="link link-hover text-sm text-base-content/60">
|
||||||
|
← Back to home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<form onSubmit={onSubmit} className="space-y-2">
|
|
||||||
<fieldset className="fieldset">
|
|
||||||
<legend className="fieldset-legend">Username</legend>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
type="text"
|
|
||||||
autoComplete="username"
|
|
||||||
placeholder="your_username"
|
|
||||||
className={`input w-full ${errors.username ? "input-error" : ""}`}
|
|
||||||
{...field("username")}
|
|
||||||
/>
|
|
||||||
{errors.username ? (
|
|
||||||
<p className="label text-error">{errors.username.message}</p>
|
|
||||||
) : (
|
|
||||||
<p className="label">3-64 characters, alphanumeric and . _ -</p>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<fieldset className="fieldset">
|
|
||||||
<legend className="fieldset-legend">First Name</legend>
|
|
||||||
<input
|
|
||||||
id="firstName"
|
|
||||||
type="text"
|
|
||||||
autoComplete="given-name"
|
|
||||||
placeholder="Jane"
|
|
||||||
className={`input w-full ${errors.firstName ? "input-error" : ""}`}
|
|
||||||
{...field("firstName")}
|
|
||||||
/>
|
|
||||||
{errors.firstName && (
|
|
||||||
<p className="label text-error">{errors.firstName.message}</p>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
<fieldset className="fieldset">
|
|
||||||
<legend className="fieldset-legend">Last Name</legend>
|
|
||||||
<input
|
|
||||||
id="lastName"
|
|
||||||
type="text"
|
|
||||||
autoComplete="family-name"
|
|
||||||
placeholder="Doe"
|
|
||||||
className={`input w-full ${errors.lastName ? "input-error" : ""}`}
|
|
||||||
{...field("lastName")}
|
|
||||||
/>
|
|
||||||
{errors.lastName && (
|
|
||||||
<p className="label text-error">{errors.lastName.message}</p>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset className="fieldset">
|
|
||||||
<legend className="fieldset-legend">Email</legend>
|
|
||||||
<input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
placeholder="jane@example.com"
|
|
||||||
className={`input w-full ${errors.email ? "input-error" : ""}`}
|
|
||||||
{...field("email")}
|
|
||||||
/>
|
|
||||||
{errors.email && (
|
|
||||||
<p className="label text-error">{errors.email.message}</p>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset className="fieldset">
|
|
||||||
<legend className="fieldset-legend">Date of Birth</legend>
|
|
||||||
<input
|
|
||||||
id="dateOfBirth"
|
|
||||||
type="date"
|
|
||||||
className={`input w-full ${errors.dateOfBirth ? "input-error" : ""}`}
|
|
||||||
{...field("dateOfBirth")}
|
|
||||||
/>
|
|
||||||
{errors.dateOfBirth ? (
|
|
||||||
<p className="label text-error">{errors.dateOfBirth.message}</p>
|
|
||||||
) : (
|
|
||||||
<p className="label">Must be 19 years or older</p>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset className="fieldset">
|
|
||||||
<legend className="fieldset-legend">Password</legend>
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
className={`input w-full ${errors.password ? "input-error" : ""}`}
|
|
||||||
{...field("password")}
|
|
||||||
/>
|
|
||||||
{errors.password ? (
|
|
||||||
<p className="label text-error">{errors.password.message}</p>
|
|
||||||
) : (
|
|
||||||
<p className="label">
|
|
||||||
8+ chars: uppercase, lowercase, digit, special character
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset className="fieldset">
|
|
||||||
<legend className="fieldset-legend">Confirm Password</legend>
|
|
||||||
<input
|
|
||||||
id="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
placeholder="••••••••"
|
|
||||||
className={`input w-full ${errors.confirmPassword ? "input-error" : ""}`}
|
|
||||||
{...field("confirmPassword")}
|
|
||||||
/>
|
|
||||||
{errors.confirmPassword && (
|
|
||||||
<p className="label text-error">
|
|
||||||
{errors.confirmPassword.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="btn btn-primary w-full mt-2"
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<span className="loading loading-spinner loading-sm" />{" "}
|
|
||||||
Creating account...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Create Account"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="divider text-xs">Already have an account?</div>
|
|
||||||
|
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<Link to="/login" className="btn btn-outline btn-sm w-full">
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className="link link-hover text-sm text-base-content/60"
|
|
||||||
>
|
|
||||||
← Back to home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
169
src/Website/app/routes/theme.tsx
Normal file
169
src/Website/app/routes/theme.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
biergartenThemes,
|
||||||
|
defaultThemeName,
|
||||||
|
isBiergartenTheme,
|
||||||
|
themeStorageKey,
|
||||||
|
type ThemeName,
|
||||||
|
} from '../lib/themes';
|
||||||
|
import type { Route } from './+types/theme';
|
||||||
|
|
||||||
|
export function meta({}: Route.MetaArgs) {
|
||||||
|
return [
|
||||||
|
{ title: 'Theme | The Biergarten App' },
|
||||||
|
{
|
||||||
|
name: 'description',
|
||||||
|
content: 'Theme guide and switcher for The Biergarten App',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme: ThemeName) {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem(themeStorageKey, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThemePage() {
|
||||||
|
const [selectedTheme, setSelectedTheme] = useState<ThemeName>(() => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return defaultThemeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedTheme = localStorage.getItem(themeStorageKey);
|
||||||
|
return isBiergartenTheme(savedTheme) ? savedTheme : defaultThemeName;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyTheme(selectedTheme);
|
||||||
|
}, [selectedTheme]);
|
||||||
|
|
||||||
|
const activeTheme =
|
||||||
|
biergartenThemes.find((theme) => theme.value === selectedTheme) ?? biergartenThemes[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-base-200 px-4 py-8 sm:px-6 lg:px-8">
|
||||||
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
|
||||||
|
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||||
|
<div className="card-body gap-4">
|
||||||
|
<h1 className="card-title text-3xl sm:text-4xl">Theme Guide</h1>
|
||||||
|
<p className="text-base-content/70">
|
||||||
|
Four themes, four moods — from the sun-bleached clarity of a Weizen afternoon
|
||||||
|
to the deep berry dark of a Cassis barrel. Every theme shares the same semantic
|
||||||
|
token structure so components stay consistent while the atmosphere shifts
|
||||||
|
completely.
|
||||||
|
</p>
|
||||||
|
<div className="alert alert-info alert-soft">
|
||||||
|
<span>
|
||||||
|
Active theme: <strong>{activeTheme.label}</strong> — {activeTheme.vibe}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||||
|
<div className="card-body gap-4">
|
||||||
|
<h2 className="card-title text-2xl">Theme switcher</h2>
|
||||||
|
<p className="text-base-content/70">Pick a theme and preview it immediately.</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="join join-vertical sm:join-horizontal"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Theme selector"
|
||||||
|
>
|
||||||
|
{biergartenThemes.map((theme) => {
|
||||||
|
const checked = selectedTheme === theme.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={theme.value}
|
||||||
|
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="theme"
|
||||||
|
value={theme.value}
|
||||||
|
className="sr-only"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => {
|
||||||
|
setSelectedTheme(theme.value);
|
||||||
|
applyTheme(theme.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{theme.label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<div className="card border border-base-300 bg-base-100 shadow-lg">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="card-title">Brand colors</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm font-medium">
|
||||||
|
<div className="rounded-box bg-primary p-3 text-primary-content">
|
||||||
|
Primary
|
||||||
|
</div>
|
||||||
|
<div className="rounded-box bg-secondary p-3 text-secondary-content">
|
||||||
|
Secondary
|
||||||
|
</div>
|
||||||
|
<div className="rounded-box bg-accent p-3 text-accent-content">Accent</div>
|
||||||
|
<div className="rounded-box bg-neutral p-3 text-neutral-content">
|
||||||
|
Neutral
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card border border-base-300 bg-base-100 shadow-lg">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="card-title">Status colors</h3>
|
||||||
|
<div className="space-y-2 text-sm font-medium">
|
||||||
|
<div className="rounded-box bg-info p-3 text-info-content">Info</div>
|
||||||
|
<div className="rounded-box bg-success p-3 text-success-content">
|
||||||
|
Success
|
||||||
|
</div>
|
||||||
|
<div className="rounded-box bg-warning p-3 text-warning-content">
|
||||||
|
Warning
|
||||||
|
</div>
|
||||||
|
<div className="rounded-box bg-error p-3 text-error-content">Error</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card border border-base-300 bg-base-100 shadow-lg md:col-span-2 xl:col-span-1">
|
||||||
|
<div className="card-body">
|
||||||
|
<h3 className="card-title">Core style outline</h3>
|
||||||
|
<ul className="list list-disc space-y-2 pl-5 text-base-content/80">
|
||||||
|
<li>Warm serif headings paired with clear sans-serif body text</li>
|
||||||
|
<li>Rounded, tactile surfaces with subtle depth and grain</li>
|
||||||
|
<li>Semantic token usage to keep contrast consistent in both themes</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card border border-base-300 bg-base-100 shadow-xl">
|
||||||
|
<div className="card-body gap-4">
|
||||||
|
<h2 className="card-title text-2xl">Component preview</h2>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button className="btn btn-primary">Primary action</button>
|
||||||
|
<button className="btn btn-secondary">Secondary action</button>
|
||||||
|
<button className="btn btn-accent">Accent action</button>
|
||||||
|
<button className="btn btn-ghost">Ghost action</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div role="alert" className="alert alert-success alert-soft">
|
||||||
|
<span>Theme tokens are applied consistently.</span>
|
||||||
|
</div>
|
||||||
|
<div role="alert" className="alert alert-warning alert-soft">
|
||||||
|
<span>Use semantic colors over hard-coded color values.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/Website/eslint.config.mjs
Normal file
52
src/Website/eslint.config.mjs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||||
|
import storybook from 'eslint-plugin-storybook';
|
||||||
|
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import prettierConfig from 'eslint-config-prettier';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['build/**', 'node_modules/**', '.react-router/**', 'coverage/**'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
...tseslint.configs.stylistic,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'no-empty-pattern': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prettierConfig,
|
||||||
|
storybook.configs['flat/recommended'],
|
||||||
|
);
|
||||||
4496
src/Website/package-lock.json
generated
4496
src/Website/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,68 @@
|
|||||||
{
|
{
|
||||||
"name": "biergarten-website",
|
"name": "biergarten-website",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "react-router dev",
|
"dev": "react-router dev",
|
||||||
"build": "react-router build",
|
"build": "react-router build",
|
||||||
"start": "NODE_ENV=production node ./build/server/index.js",
|
"start": "NODE_ENV=production node ./build/server/index.js",
|
||||||
"typecheck": "tsc"
|
"lint": "eslint .",
|
||||||
},
|
"lint:fix": "eslint . --fix",
|
||||||
"dependencies": {
|
"format": "prettier . --write",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"format:check": "prettier . --check",
|
||||||
"@react-router/dev": "^7.13.1",
|
"typegen": "react-router typegen",
|
||||||
"@react-router/express": "^7.13.1",
|
"typecheck": "npm run typegen && tsc -p tsconfig.json",
|
||||||
"@react-router/node": "^7.13.1",
|
"storybook": "storybook dev -p 6006",
|
||||||
"isbot": "^5.1.36",
|
"build-storybook": "storybook build",
|
||||||
"react": "^19.2.4",
|
"test:storybook": "vitest run --project storybook",
|
||||||
"react-dom": "^19.2.4",
|
"test:storybook:playwright": "playwright test -c playwright.storybook.config.ts"
|
||||||
"react-hook-form": "^7.71.2",
|
},
|
||||||
"react-router": "7.13.1",
|
"dependencies": {
|
||||||
"zod": "^3.23.8"
|
"@headlessui/react": "^2.2.9",
|
||||||
},
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"devDependencies": {
|
"@react-router/dev": "^7.13.1",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@react-router/express": "^7.13.1",
|
||||||
"@types/express": "^5.0.6",
|
"@react-router/node": "^7.13.1",
|
||||||
"@types/node": "^25.5.0",
|
"iconoir-react": "^7.11.0",
|
||||||
"@types/react": "^19.2.14",
|
"isbot": "^5.1.36",
|
||||||
"@types/react-dom": "^19.2.3",
|
"react": "^19.2.4",
|
||||||
"autoprefixer": "^10.4.27",
|
"react-dom": "^19.2.4",
|
||||||
"daisyui": "^5.5.19",
|
"react-hook-form": "^7.71.2",
|
||||||
"postcss": "^8.5.8",
|
"react-hot-toast": "^2.6.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"react-router": "^7.13.1",
|
||||||
"typescript": "^5.9.3",
|
"zod": "^4.3.6"
|
||||||
"vite": "^8.0.0"
|
},
|
||||||
}
|
"devDependencies": {
|
||||||
|
"@chromatic-com/storybook": "^5.0.1",
|
||||||
|
"@eslint/js": "^9.0.0",
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
|
"@storybook/addon-a11y": "^10.2.19",
|
||||||
|
"@storybook/addon-docs": "^10.2.19",
|
||||||
|
"@storybook/addon-onboarding": "^10.2.19",
|
||||||
|
"@storybook/addon-vitest": "^10.2.19",
|
||||||
|
"@storybook/react-vite": "^10.2.19",
|
||||||
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitest/browser-playwright": "^4.1.0",
|
||||||
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
|
"daisyui": "^5.5.19",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-storybook": "^10.2.19",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
|
"postcss": "^8.5.8",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"storybook": "^10.2.19",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.57.0",
|
||||||
|
"vite": "^7.0.0",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/Website/playwright.storybook.config.ts
Normal file
20
src/Website/playwright.storybook.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
const port = process.env.STORYBOOK_PORT ?? '6006';
|
||||||
|
const baseURL = process.env.STORYBOOK_URL ?? `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/playwright',
|
||||||
|
timeout: 30_000,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: `npm run storybook -- --ci --port ${port}`,
|
||||||
|
url: baseURL,
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 120_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
'@tailwindcss/postcss': {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Config } from "@react-router/dev/config";
|
import type { Config } from '@react-router/dev/config';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
ssr: true,
|
ssr: true,
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
41
src/Website/stories/Configure.mdx
Normal file
41
src/Website/stories/Configure.mdx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Meta } from '@storybook/addon-docs/blocks';
|
||||||
|
|
||||||
|
<Meta title="Docs/Storybook" />
|
||||||
|
|
||||||
|
# Biergarten Storybook
|
||||||
|
|
||||||
|
This Storybook is scoped to real app UI only:
|
||||||
|
|
||||||
|
- `SubmitButton`
|
||||||
|
- `FormField`
|
||||||
|
- `Navbar`
|
||||||
|
- `Themes` gallery
|
||||||
|
|
||||||
|
## Theme workflow
|
||||||
|
|
||||||
|
Use the toolbar theme switcher to preview all Biergarten themes:
|
||||||
|
|
||||||
|
- `biergarten-lager`
|
||||||
|
- `biergarten-stout`
|
||||||
|
- `biergarten-cassis`
|
||||||
|
- `biergarten-weizen`
|
||||||
|
|
||||||
|
Stories are rendered inside a decorator that sets `data-theme`, so tokens and components reflect production styling.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Two layers are enabled:
|
||||||
|
|
||||||
|
1. Story `play` tests (Storybook test runner / Vitest addon)
|
||||||
|
2. Browser checks with Playwright against Storybook iframe routes
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
- `npm run build-storybook -- --test`
|
||||||
|
- `npm run test:storybook:playwright`
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Add stories only for reusable app components.
|
||||||
|
- Prefer semantic classes (`bg-primary`, `text-base-content`, etc.).
|
||||||
|
- Keep stories state-focused and minimal.
|
||||||
68
src/Website/stories/FormField.stories.tsx
Normal file
68
src/Website/stories/FormField.stories.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, within } from 'storybook/test';
|
||||||
|
import FormField from '../app/components/forms/FormField';
|
||||||
|
|
||||||
|
const formFieldDescription = `Reusable labeled input for Biergarten forms. This page shows guided, error, and password states so you can review label spacing, helper text, validation messaging, and ARIA behavior in the same card layout used across the app.`;
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Forms/FormField',
|
||||||
|
component: FormField,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
args: {
|
||||||
|
id: 'email',
|
||||||
|
name: 'email',
|
||||||
|
type: 'email',
|
||||||
|
label: 'Email address',
|
||||||
|
placeholder: 'you@example.com',
|
||||||
|
hint: 'We only use this to manage your account.',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: formFieldDescription,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (args) => (
|
||||||
|
<div className="w-full max-w-md rounded-box bg-base-100 p-6 shadow-lg">
|
||||||
|
<FormField {...args} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
} satisfies Meta<typeof FormField>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const WithHint: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(canvas.getByLabelText(/email address/i)).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText(/manage your account/i)).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithError: Story = {
|
||||||
|
args: {
|
||||||
|
error: 'Please enter a valid email address.',
|
||||||
|
hint: undefined,
|
||||||
|
'aria-invalid': true,
|
||||||
|
defaultValue: 'not-an-email',
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(canvas.getByText(/valid email address/i)).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByLabelText(/email address/i)).toHaveAttribute('aria-invalid', 'true');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PasswordField: Story = {
|
||||||
|
args: {
|
||||||
|
id: 'password',
|
||||||
|
name: 'password',
|
||||||
|
type: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
placeholder: 'Enter a strong password',
|
||||||
|
hint: 'Use 12 or more characters.',
|
||||||
|
},
|
||||||
|
};
|
||||||
69
src/Website/stories/Navbar.stories.tsx
Normal file
69
src/Website/stories/Navbar.stories.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, userEvent, within } from 'storybook/test';
|
||||||
|
import Navbar from '../app/components/Navbar';
|
||||||
|
|
||||||
|
const navbarDescription = `Top-level navigation for the Biergarten website. These stories cover guest, authenticated, and mobile states so you can review branding, route visibility, account menu behavior, and responsive collapse without leaving Storybook.`;
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Navigation/Navbar',
|
||||||
|
component: Navbar,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: navbarDescription,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof Navbar>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Guest: Story = {
|
||||||
|
args: {
|
||||||
|
auth: null,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(canvas.getByRole('link', { name: /the biergarten app/i })).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByRole('link', { name: /login/i })).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByRole('link', { name: /register user/i })).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Authenticated: Story = {
|
||||||
|
args: {
|
||||||
|
auth: {
|
||||||
|
username: 'Hans',
|
||||||
|
accessToken: 'access-token',
|
||||||
|
refreshToken: 'refresh-token',
|
||||||
|
userAccountId: 'user-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const userButton = canvas.getByRole('button', { name: /hans/i });
|
||||||
|
await expect(userButton).toBeInTheDocument();
|
||||||
|
await userEvent.click(userButton);
|
||||||
|
await expect(canvas.getByRole('menuitem', { name: /dashboard/i })).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByRole('menuitem', { name: /logout/i })).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MobileMenu: Story = {
|
||||||
|
args: {
|
||||||
|
auth: null,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
viewport: {
|
||||||
|
defaultViewport: 'mobile1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(canvas.getByRole('button', { name: /toggle navigation/i }));
|
||||||
|
await expect(canvas.getByRole('link', { name: /beer styles/i })).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
52
src/Website/stories/SubmitButton.stories.tsx
Normal file
52
src/Website/stories/SubmitButton.stories.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, within } from 'storybook/test';
|
||||||
|
import SubmitButton from '../app/components/forms/SubmitButton';
|
||||||
|
|
||||||
|
const submitButtonDescription = `Shared submit action for Biergarten forms. These stories cover the idle, loading, and custom-width states so you can verify button copy, disabled behavior during submission, and theme styling without wiring up a full form flow.`;
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Forms/SubmitButton',
|
||||||
|
component: SubmitButton,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
args: {
|
||||||
|
idleText: 'Save changes',
|
||||||
|
submittingText: 'Saving changes',
|
||||||
|
isSubmitting: false,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: submitButtonDescription,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof SubmitButton>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Idle: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(canvas.getByRole('button', { name: /save changes/i })).toBeEnabled();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Submitting: Story = {
|
||||||
|
args: {
|
||||||
|
isSubmitting: true,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(canvas.getByRole('button', { name: /saving changes/i })).toBeDisabled();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomWidth: Story = {
|
||||||
|
args: {
|
||||||
|
className: 'btn btn-secondary min-w-64',
|
||||||
|
idleText: 'Register user',
|
||||||
|
submittingText: 'Registering user',
|
||||||
|
},
|
||||||
|
};
|
||||||
157
src/Website/stories/Themes.stories.tsx
Normal file
157
src/Website/stories/Themes.stories.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, within } from 'storybook/test';
|
||||||
|
import { biergartenThemes } from '../app/lib/themes';
|
||||||
|
|
||||||
|
const themesDescription = `Palette reference for all Biergarten themes. Each panel shows the main semantic color pairs, status tokens, and custom content tokens so you can catch contrast issues, pairing mistakes, and mood drift before they show up in real components.`;
|
||||||
|
|
||||||
|
function ThemeSwatch({ label, className }: { label: string; className: string }) {
|
||||||
|
return <div className={`rounded-box p-3 text-sm font-medium ${className}`}>{label}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For custom tokens not covered by Tailwind utilities (surface, muted, highlight). */
|
||||||
|
function CssVarSwatch({ label, bg, color }: { label: string; bg: string; color: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-box p-3 text-sm font-medium"
|
||||||
|
style={{ backgroundColor: `var(${bg})`, color: `var(${color})` }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextTokenSample({
|
||||||
|
label,
|
||||||
|
background,
|
||||||
|
text,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
background: string;
|
||||||
|
text: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-box p-3" style={{ backgroundColor: `var(${background})` }}>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium" style={{ color: `var(${text})` }}>
|
||||||
|
Secondary copy, placeholders, and helper text.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemePanel({ label, value, vibe }: { label: string; value: string; vibe: string }) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
data-theme={value}
|
||||||
|
className="rounded-box border border-base-300 bg-base-100 shadow-lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-4 p-5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold">{label}</h2>
|
||||||
|
<p className="text-sm text-base-content/70">{vibe}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Core palette */}
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||||
|
Core
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<ThemeSwatch label="Primary" className="bg-primary text-primary-content" />
|
||||||
|
<ThemeSwatch label="Secondary" className="bg-secondary text-secondary-content" />
|
||||||
|
<ThemeSwatch label="Accent" className="bg-accent text-accent-content" />
|
||||||
|
<ThemeSwatch label="Neutral" className="bg-neutral text-neutral-content" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status tokens */}
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4">
|
||||||
|
<ThemeSwatch label="Info" className="bg-info text-info-content" />
|
||||||
|
<ThemeSwatch label="Success" className="bg-success text-success-content" />
|
||||||
|
<ThemeSwatch label="Warning" className="bg-warning text-warning-content" />
|
||||||
|
<ThemeSwatch label="Error" className="bg-error text-error-content" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content tokens (custom) */}
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||||
|
Content
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
|
<CssVarSwatch
|
||||||
|
label="Surface"
|
||||||
|
bg="--color-surface"
|
||||||
|
color="--color-surface-content"
|
||||||
|
/>
|
||||||
|
<TextTokenSample
|
||||||
|
label="Muted on Base"
|
||||||
|
background="--color-base-100"
|
||||||
|
text="--color-muted"
|
||||||
|
/>
|
||||||
|
<CssVarSwatch
|
||||||
|
label="Highlight"
|
||||||
|
bg="--color-highlight"
|
||||||
|
color="--color-highlight-content"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<TextTokenSample
|
||||||
|
label="Muted on Surface"
|
||||||
|
background="--color-surface"
|
||||||
|
text="--color-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button className="btn btn-primary btn-sm">Primary</button>
|
||||||
|
<button className="btn btn-secondary btn-sm">Secondary</button>
|
||||||
|
<button className="btn btn-outline btn-sm">Outline</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="alert" className="alert alert-success alert-soft">
|
||||||
|
<span>Semantic tokens stay stable while the atmosphere changes.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Themes/Biergarten Themes',
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: themesDescription,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
render: () => (
|
||||||
|
<div className="grid gap-6 p-6 lg:grid-cols-2">
|
||||||
|
{biergartenThemes.map((theme) => (
|
||||||
|
<ThemePanel key={theme.value} {...theme} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
} satisfies Meta;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Gallery: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
for (const theme of biergartenThemes) {
|
||||||
|
await expect(canvas.getByRole('heading', { name: theme.label })).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
74
src/Website/stories/Toast.stories.tsx
Normal file
74
src/Website/stories/Toast.stories.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||||
|
import ToastProvider from '../app/components/toast/ToastProvider';
|
||||||
|
import {
|
||||||
|
dismissToasts,
|
||||||
|
showErrorToast,
|
||||||
|
showInfoToast,
|
||||||
|
showSuccessToast,
|
||||||
|
} from '../app/components/toast/toast';
|
||||||
|
|
||||||
|
const toastDescription = `Theme-aware toast feedback built on react-hot-toast. Use this page to trigger success, error, and info messages, check icon contrast and surface styling, and confirm notifications feel consistent across Biergarten themes.`;
|
||||||
|
|
||||||
|
function ToastDemo() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ToastProvider />
|
||||||
|
<div className="card border border-base-300 bg-base-100 shadow-md">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title">Toast demo</h2>
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
Use these actions to preview toast styles.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-success btn-sm"
|
||||||
|
onClick={() => showSuccessToast('Saved successfully')}
|
||||||
|
>
|
||||||
|
Success
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-error btn-sm"
|
||||||
|
onClick={() => showErrorToast('Something went wrong')}
|
||||||
|
>
|
||||||
|
Error
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-info btn-sm"
|
||||||
|
onClick={() => showInfoToast('Heads up: check your email')}
|
||||||
|
>
|
||||||
|
Info
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={dismissToasts}>
|
||||||
|
Dismiss all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Feedback/Toast',
|
||||||
|
component: ToastDemo,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: toastDescription,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof ToastDemo>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Playground: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(canvas.getByRole('button', { name: /success/i }));
|
||||||
|
await expect(screen.getByText(/saved successfully/i)).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ["./app/**/*.{ts,tsx}"],
|
content: ['./app/**/*.{ts,tsx}'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
},
|
},
|
||||||
plugins: [require("daisyui")],
|
plugins: [require('daisyui')],
|
||||||
};
|
};
|
||||||
|
|||||||
49
src/Website/tests/playwright/storybook.components.spec.ts
Normal file
49
src/Website/tests/playwright/storybook.components.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
const themes = [
|
||||||
|
'biergarten-lager',
|
||||||
|
'biergarten-stout',
|
||||||
|
'biergarten-cassis',
|
||||||
|
'biergarten-weizen',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test.describe('storybook component coverage', () => {
|
||||||
|
for (const theme of themes) {
|
||||||
|
test(`SubmitButton idle renders in ${theme}`, async ({ page }) => {
|
||||||
|
await page.goto(`/iframe.html?id=forms-submitbutton--idle&globals=theme:${theme}`);
|
||||||
|
await expect(page.getByRole('button', { name: /save changes/i })).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`FormField error renders in ${theme}`, async ({ page }) => {
|
||||||
|
await page.goto(`/iframe.html?id=forms-formfield--with-error&globals=theme:${theme}`);
|
||||||
|
await expect(page.getByLabel('Email address')).toBeVisible();
|
||||||
|
await expect(page.getByText(/valid email address/i)).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`Navbar guest renders in ${theme}`, async ({ page }) => {
|
||||||
|
await page.goto(`/iframe.html?id=navigation-navbar--guest&globals=theme:${theme}`);
|
||||||
|
await expect(page.getByRole('link', { name: /the biergarten app/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: /^login$/i })).toBeVisible();
|
||||||
|
await expect(page.locator(`[data-theme=\"${theme}\"]`)).toBeVisible();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Navbar authenticated state renders', async ({ page }) => {
|
||||||
|
await page.goto(
|
||||||
|
`/iframe.html?id=navigation-navbar--authenticated&globals=theme:biergarten-stout`,
|
||||||
|
);
|
||||||
|
await expect(page.getByRole('button', { name: /hans/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Theme gallery shows all themes', async ({ page }) => {
|
||||||
|
await page.goto(
|
||||||
|
`/iframe.html?id=themes-biergarten-themes--gallery&globals=theme:biergarten-lager`,
|
||||||
|
);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Biergarten Lager' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Biergarten Stout' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Biergarten Cassis' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Biergarten Weizen' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"charset": "utf8",
|
"esModuleInterop": true,
|
||||||
"esModuleInterop": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"isolatedModules": true,
|
||||||
"isolatedModules": true,
|
"jsx": "react-jsx",
|
||||||
"jsx": "react-jsx",
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
"moduleResolution": "bundler",
|
||||||
"moduleResolution": "bundler",
|
"module": "ESNext",
|
||||||
"module": "ESNext",
|
"noEmit": true,
|
||||||
"noEmit": true,
|
"rootDirs": [".", "./.react-router/types"],
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"target": "ES2022",
|
"types": ["node", "vite/client"],
|
||||||
"skipLibCheck": true
|
"target": "ES2022",
|
||||||
},
|
"skipLibCheck": true
|
||||||
"include": ["app"],
|
},
|
||||||
"exclude": ["node_modules"]
|
"include": ["app", ".react-router/types/**/*", "react-router.config.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,47 @@
|
|||||||
import { reactRouter } from "@react-router/dev/vite";
|
/// <reference types="vitest/config" />
|
||||||
import { defineConfig } from "vite";
|
import { reactRouter } from '@react-router/dev/vite';
|
||||||
|
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
|
||||||
|
import { playwright } from '@vitest/browser-playwright';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
const dirname =
|
||||||
|
typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const isStorybook =
|
||||||
|
process.env.STORYBOOK === 'true' || process.argv.some((arg) => arg.includes('storybook'));
|
||||||
|
|
||||||
|
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [reactRouter()],
|
plugins: isStorybook ? [] : [reactRouter()],
|
||||||
|
resolve: {
|
||||||
|
dedupe: ['react', 'react-dom'],
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
extends: true,
|
||||||
|
plugins: [
|
||||||
|
// The plugin will run tests for the stories defined in your Storybook config
|
||||||
|
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
|
||||||
|
storybookTest({
|
||||||
|
configDir: path.join(dirname, '.storybook'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
test: {
|
||||||
|
name: 'storybook',
|
||||||
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
headless: true,
|
||||||
|
provider: playwright({}),
|
||||||
|
instances: [
|
||||||
|
{
|
||||||
|
browser: 'chromium',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
setupFiles: ['.storybook/vitest.setup.ts'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
1
src/Website/vitest.shims.d.ts
vendored
Normal file
1
src/Website/vitest.shims.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="@vitest/browser-playwright" />
|
||||||
Reference in New Issue
Block a user