mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 18:14:44 +00:00
Compare commits
1 Commits
feat/auth-
...
4105914633
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4105914633 |
101
.github/pull_request_template.md
vendored
101
.github/pull_request_template.md
vendored
@@ -1,101 +0,0 @@
|
|||||||
## Summary
|
|
||||||
|
|
||||||
<!--
|
|
||||||
State WHAT is changing and WHY, in two to five sentences.
|
|
||||||
A reader should understand the change without opening the diff.
|
|
||||||
Describe the problem first, then the outcome.
|
|
||||||
-->
|
|
||||||
|
|
||||||
## Type of change
|
|
||||||
|
|
||||||
<!-- Tick the one option that best describes this PR. -->
|
|
||||||
|
|
||||||
- [ ] Bug fix
|
|
||||||
- [ ] New feature
|
|
||||||
- [ ] Refactor (no change in behaviour)
|
|
||||||
- [ ] Performance improvement
|
|
||||||
- [ ] Documentation
|
|
||||||
- [ ] Tests only
|
|
||||||
- [ ] Build, CI or dependency update
|
|
||||||
- [ ] Other (please specify):
|
|
||||||
|
|
||||||
## Related issues
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Link the issue(s) this PR relates to. Use one keyword per issue so that each is closed on merge:
|
|
||||||
|
|
||||||
Fixes #123
|
|
||||||
Fixes #124, fixes #125
|
|
||||||
Fixes owner/other-repo#456
|
|
||||||
|
|
||||||
Closing keywords only take effect when the PR targets the repository's default branch.
|
|
||||||
Use "Related to #123" if the PR should not close the issue.
|
|
||||||
If there is no issue, write "N/A" and explain the motivation in the Summary.
|
|
||||||
-->
|
|
||||||
|
|
||||||
## Approach and context
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Explain how the change works and why this approach was chosen.
|
|
||||||
Include, where relevant:
|
|
||||||
- The decisions you made that are not obvious from the code.
|
|
||||||
- Alternatives you considered and why you rejected them.
|
|
||||||
- Known limitations or shortcomings, and any planned follow-up work.
|
|
||||||
- Background such as benchmark results or design documents.
|
|
||||||
|
|
||||||
Summarise the key points of any external link. Links may be inaccessible to future readers.
|
|
||||||
-->
|
|
||||||
|
|
||||||
## How this was tested
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Describe what you ran and what you observed. Be specific, and state only what you actually did.
|
|
||||||
For example: the automated tests added or changed, manual steps and their outcome,
|
|
||||||
environments or platforms covered, and anything you were unable to test.
|
|
||||||
For a bug fix, note the test that fails without the fix.
|
|
||||||
-->
|
|
||||||
|
|
||||||
## Compatibility and risk
|
|
||||||
|
|
||||||
- [ ] This change is backwards compatible.
|
|
||||||
- [ ] This change is **breaking** (complete the details below).
|
|
||||||
- [ ] This change deprecates existing behaviour (complete the details below).
|
|
||||||
|
|
||||||
<!--
|
|
||||||
If breaking or deprecating, describe: what changes, who is affected,
|
|
||||||
and the migration path or recommended alternative.
|
|
||||||
Also note any deployment, configuration, data migration or rollback considerations.
|
|
||||||
-->
|
|
||||||
|
|
||||||
## User-facing change
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Does this PR change behaviour that users, operators or API consumers will notice?
|
|
||||||
If not, write "None".
|
|
||||||
If so, write one or two sentences suitable for release notes, in plain language and from the
|
|
||||||
user's point of view. If users must take action when upgrading, state clearly that action is required.
|
|
||||||
-->
|
|
||||||
|
|
||||||
```release-note
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes for reviewers
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Help reviewers focus. For example:
|
|
||||||
- Where to start, or a suggested order in which to read the files.
|
|
||||||
- Areas where you would particularly value scrutiny, or where you are uncertain.
|
|
||||||
- How any mechanical, generated or bulk changes were produced (for example a search-and-replace
|
|
||||||
or a code generator), so that reviewers can reproduce the steps.
|
|
||||||
- Screenshots or recordings for visual changes (include before and after where useful).
|
|
||||||
-->
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
- [ ] The PR has a single, focused purpose and contains no unrelated changes.
|
|
||||||
- [ ] The title is a clear, imperative summary of the change (for example, "Add retry to payment client").
|
|
||||||
- [ ] I have added or updated tests that cover this change.
|
|
||||||
- [ ] The build succeeds and the tests pass locally.
|
|
||||||
- [ ] The code follows the existing patterns and style of this repository.
|
|
||||||
- [ ] I have updated documentation, changelogs or API references where required.
|
|
||||||
- [ ] I have reviewed my own diff, and this description reflects the final state of the change.
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -507,3 +507,4 @@ web/test-results/frontend/playwright-storybook.xml
|
|||||||
|
|
||||||
# mkdocs build output
|
# mkdocs build output
|
||||||
/site
|
/site
|
||||||
|
|
||||||
|
|||||||
@@ -566,11 +566,10 @@ are not seeded either.
|
|||||||
**Environments**:
|
**Environments**:
|
||||||
|
|
||||||
| Environment | Compose file |
|
| Environment | Compose file |
|
||||||
| --------------- | ------------------------------- |
|
| ----------- | -------------------------- |
|
||||||
| Development | `docker-compose.dev.yaml` |
|
| Development | `docker-compose.dev.yaml` |
|
||||||
| Testing | `docker-compose.test.yaml` |
|
| Testing | `docker-compose.test.yaml` |
|
||||||
| Production | `docker-compose.prod.yaml` |
|
| Production | `docker-compose.prod.yaml` |
|
||||||
| Production seed | `docker-compose.prod.seed.yaml` |
|
|
||||||
|
|
||||||
For details, see [Docker Guide](docker.md).
|
For details, see [Docker Guide](docker.md).
|
||||||
|
|
||||||
|
|||||||
@@ -76,14 +76,14 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
|
|||||||
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique |
|
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique |
|
||||||
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
|
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
|
||||||
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email |
|
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email |
|
||||||
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository`, read via `IUserListRepository`'s left joins in both `GetByIdAsync` and `GetAllAsync` |
|
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository`, read via `IUserListRepository.GetByIdAsync`'s left join |
|
||||||
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync`, read via `IUserListRepository`'s `ValidTo IS NULL` join for the active avatar |
|
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` |
|
||||||
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
|
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
|
||||||
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly |
|
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly |
|
||||||
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; `CityName` and `StateProvinceID` together are unique; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` |
|
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; `CityName` and `StateProvinceID` together are unique; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` |
|
||||||
| `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
|
| `StateProvince` | `Domain.Entities.StateProvince`, `Features.Locations` | Referenced by `City.StateProvinceID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
|
||||||
| `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
|
| `Country` | `Domain.Entities.Country`, `Features.Locations` | Referenced by `StateProvince.CountryID`; created on demand by `ILocationRepository.GetOrCreateCityIdAsync`, read via `IBreweryRepository`'s location joins |
|
||||||
| `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; `Hyperlink` holds the object key within the bucket, which readers sign into a time-limited URL via `IFileStorageProvider.GetPresignedUrl`; the active avatar's row is also read via `IUserListRepository`'s `Media.Photo` join; not yet seeded by `Database.Seed` |
|
| `Photo` | `Domain.Entities.Photo`, `Features.PhotoUpload` | Written via `IPhotoUploadRepository.CreateAsync`, sent as an `UploadPhotoCommand` from other features' handlers; not yet seeded by `Database.Seed` |
|
||||||
|
|
||||||
### Tables defined in the schema but not yet wired to a feature slice
|
### Tables defined in the schema but not yet wired to a feature slice
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ no data flowing over them.
|
|||||||
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). |
|
| **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). |
|
||||||
| **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, in parallel once seeding completes; results are aggregated. |
|
| **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, in parallel once seeding completes; results are aggregated. |
|
||||||
| **Prod network** (`prodnet`, bridge, named `biergarten-prodnet`) | Isolated from dev/test networks. Volumes: `biergartendata-prod` and `seaweedfsdata-prod`, both external, so `down -v` keeps them. Startup order: SeaweedFS (health check) → API (health check) → frontend, with `frontend` the only service publishing a host port, bound to `127.0.0.1`. Migrating and seeding run on demand as their own run-once jobs joining `biergarten-prodnet`, so an `up` or a reboot replays neither. |
|
| **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `biergartendata-prod` and `seaweedfsdata-prod` (both external, so `down -v` keeps them), plus `nuget-cache-prod`. Startup order matches dev: SQL Server (health check) → migrations → seed → API → frontend, with `frontend` the only service publishing a host port. |
|
||||||
| **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. |
|
| **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. |
|
||||||
| **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. |
|
| **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. |
|
||||||
| `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. |
|
| `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. |
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
box Production Environment (docker-compose.prod.yaml)
|
box Production Environment (docker-compose.prod.yaml)
|
||||||
participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published
|
participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published
|
||||||
participant ProdS3 as SeaweedFS<br/>S3 gateway :8333<br/>no host port published
|
participant ProdMig as Migrations (run-once)
|
||||||
participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
|
participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
|
||||||
participant ProdWeb as Frontend Container<br/>Express + React Router<br/>127.0.0.1:5656
|
|
||||||
end
|
end
|
||||||
|
|
||||||
ProdAPI-->>ProdS3: depends_on: seaweedfs healthy
|
ProdMig-->>ProdDB: depends_on
|
||||||
ProdWeb-->>ProdAPI: depends_on: api.core healthy
|
ProdMig->>ProdDB: Run migrations
|
||||||
|
ProdAPI-->>ProdDB: depends_on: sql-server healthy (not migrations/seed)
|
||||||
ProdAPI->>ProdDB: Connect and serve
|
ProdAPI->>ProdDB: Connect and serve
|
||||||
ProdAPI->>ProdS3: Store and read photos
|
|
||||||
Note over ProdDB: Migrating and seeding are separate run-once jobs,<br/>run at deploy time rather than by the stack
|
|
||||||
|
|||||||
@@ -134,32 +134,26 @@ containers, which would end the run prematurely. See
|
|||||||
- Production logging levels
|
- Production logging levels
|
||||||
- No database clearing
|
- No database clearing
|
||||||
- Optimized build configurations
|
- Optimized build configurations
|
||||||
- Health checks on every long-running service
|
- Health checks enabled
|
||||||
- Restart policies that survive a reboot (see
|
- Restart policies (unless-stopped)
|
||||||
[Restart tolerance](#restart-tolerance))
|
|
||||||
- Security hardening
|
- Security hardening
|
||||||
- Only `frontend` publishes a port to the host, bound to `127.0.0.1` so a reverse
|
- Only `frontend` publishes a port to the host, bound to `127.0.0.1` so a reverse
|
||||||
proxy can sit in front of it; `sqlserver`, `api.core` and `seaweedfs` are
|
proxy can sit in front of it; `sqlserver`, `api.core` and `seaweedfs` are
|
||||||
reachable only from other containers on `prodnet` (named `biergarten-prodnet`
|
reachable only from other containers on `prodnet`
|
||||||
so the seed job can join it from its own Compose project)
|
- Seeded catalogue, with its AI-generated origin disclosed in the app. See
|
||||||
- Seeding is a separate, explicitly run compose file
|
[Production data](#production-data) below.
|
||||||
(`docker-compose.prod.seed.yaml`), so an `up` or a restart leaves the existing
|
|
||||||
catalogue as it is. See [Production data](#production-data) below.
|
|
||||||
|
|
||||||
**Services**:
|
**Services**:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
sqlserver # Production SQL Server (internal only)
|
sqlserver # Production SQL Server (internal only)
|
||||||
|
database.migrations # Schema updates only
|
||||||
|
database.seed # Loads the catalogue, runs once
|
||||||
api.core # Production API (internal only, no published ports)
|
api.core # Production API (internal only, no published ports)
|
||||||
seaweedfs # S3-compatible object storage (internal only)
|
seaweedfs # S3-compatible object storage (internal only)
|
||||||
frontend # Production website (127.0.0.1:5656, the only host binding)
|
frontend # Production website (127.0.0.1:5656, the only host binding)
|
||||||
```
|
```
|
||||||
|
|
||||||
Migrating and seeding are run-once jobs kept outside this file, so an `up` or a
|
|
||||||
restart touches neither the schema nor the catalogue. Run them explicitly at
|
|
||||||
deploy time: see [Migrating production](#migrating-production) and
|
|
||||||
[Seeding production](#seeding-production-docker-composeprodseedyaml).
|
|
||||||
|
|
||||||
**Deploy Production**:
|
**Deploy Production**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -175,94 +169,23 @@ Docker seeds each volume with the ownership the image expects and labels it for
|
|||||||
SELinux, so the host needs no `chown` and no `:z`/`:Z` mount options on
|
SELinux, so the host needs no `chown` and no `:z`/`:Z` mount options on
|
||||||
enforcing distributions such as RHEL, AlmaLinux and Fedora.
|
enforcing distributions such as RHEL, AlmaLinux and Fedora.
|
||||||
|
|
||||||
#### Migrating production
|
|
||||||
|
|
||||||
`database.migrations` is a run-once job rather than a service in the prod stack,
|
|
||||||
so the schema is migrated when an operator asks for it instead of on every boot.
|
|
||||||
Build the image and run it against the network the prod stack creates:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t database.migrations \
|
|
||||||
-f web/backend/Database/Database.Migrations/Dockerfile \
|
|
||||||
--build-arg BUILD_CONFIGURATION=Release --build-arg APP_UID=1000 \
|
|
||||||
web/backend
|
|
||||||
|
|
||||||
docker run --rm --env-file web/.env.prod --network biergarten-prodnet \
|
|
||||||
-e DOTNET_RUNNING_IN_CONTAINER=true database.migrations
|
|
||||||
```
|
|
||||||
|
|
||||||
DbUp records every script it has applied, so a re-run applies only what is new.
|
|
||||||
Run this with `sqlserver` already up and healthy, and before the seed job.
|
|
||||||
|
|
||||||
#### Restart tolerance
|
|
||||||
|
|
||||||
`sqlserver`, `api.core`, `seaweedfs` and `frontend` run under
|
|
||||||
`restart: unless-stopped`, so the stack returns on its own after a container
|
|
||||||
crash, a Docker daemon restart or a host reboot, provided Docker itself starts at
|
|
||||||
boot (`systemctl enable docker`). The migration and seed jobs stay out of the
|
|
||||||
stack, so a reboot replays neither.
|
|
||||||
|
|
||||||
The daemon replays restart policies without replaying the `depends_on` graph, so
|
|
||||||
after a reboot the containers come back in arbitrary order and each one tolerates
|
|
||||||
a dependency that arrives late. `api.core` opens its database and S3 connections
|
|
||||||
per request rather than at startup, and `frontend` answers with its own error
|
|
||||||
states while `api.core` is still coming up.
|
|
||||||
|
|
||||||
Health checks report when the stack has converged:
|
|
||||||
|
|
||||||
| Service | Probe |
|
|
||||||
| --- | --- |
|
|
||||||
| `sqlserver` | `sqlcmd -Q 'SELECT 1'`, with a 30s start period and 12 retries |
|
|
||||||
| `api.core` | `GET /health` spoken over bash's `/dev/tcp` — the .NET runtime image carries bash and no HTTP client |
|
|
||||||
| `seaweedfs` | `curl http://localhost:8333/status`, the S3 gateway, which serves once the master, volume and filer servers are up |
|
|
||||||
| `frontend` | `wget --spider http://127.0.0.1:5656/healthz`, answered by Express alone and so independent of the API |
|
|
||||||
|
|
||||||
`sqlserver` also carries `stop_grace_period: 60s`, giving a checkpoint time to
|
|
||||||
finish on shutdown instead of being recovered on the next start.
|
|
||||||
|
|
||||||
Deploy and wait for the stack to report healthy, or check convergence after a
|
|
||||||
reboot:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d --wait
|
|
||||||
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml ps
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Seeding production (`docker-compose.prod.seed.yaml`)
|
|
||||||
|
|
||||||
`database.seed` lives in its own compose file, run on demand once the prod stack
|
|
||||||
is up, `sqlserver` is healthy, and the schema is migrated:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml \
|
|
||||||
run --rm --build database.seed
|
|
||||||
```
|
|
||||||
|
|
||||||
The job runs as the `biergarten-prod-seed` Compose project and attaches to the
|
|
||||||
external `biergarten-prodnet` network, which the main prod stack creates, so it
|
|
||||||
reaches `sqlserver` and `seaweedfs` under the same hostnames the API uses. It
|
|
||||||
exits when seeding finishes; `--rm` removes the container. Bring the prod stack
|
|
||||||
up first, because the seed project has no visibility into the other project's
|
|
||||||
health checks, so a run started before SQL Server is accepting connections fails.
|
|
||||||
|
|
||||||
#### Production data
|
#### Production data
|
||||||
|
|
||||||
A production deployment seeded this way serves the AI-generated fixture data
|
Production runs `database.seed`, so the catalogue it serves is the AI-generated
|
||||||
produced by the pipeline. Per
|
fixture data produced by the pipeline. Per
|
||||||
[Pipeline ethics, bias, and known issues](../pipeline/ETHICS-AND-KNOWN-ISSUES.md),
|
[Pipeline ethics, bias, and known issues](../pipeline/ETHICS-AND-KNOWN-ISSUES.md),
|
||||||
anyone interacting with an application seeded from it has to be told upfront that
|
anyone interacting with an application seeded from it has to be told upfront that
|
||||||
the content is AI-generated.
|
the content is AI-generated.
|
||||||
|
|
||||||
The website meets that obligation with `AiDisclosureDialog`
|
The website meets that obligation with `AiDisclosureDialog`
|
||||||
(`web/frontend/app/features/home/components/AiDisclosureDialog.tsx`), which
|
(`web/frontend/app/features/disclosure/components/AiDisclosureDialog.tsx`), which
|
||||||
the root route renders on every full page load, whichever route the visitor lands
|
the root route renders on every full page load, whichever route the visitor lands
|
||||||
on. It is part of the server-rendered HTML rather than mounted after hydration, so
|
on. It is part of the server-rendered HTML rather than mounted after hydration, so
|
||||||
the disclosure is on screen with the first paint. Acknowledging it hides the
|
the disclosure is on screen with the first paint. Acknowledging it hides the
|
||||||
dialog for the remainder of that page load; a new load shows it again.
|
dialog for the remainder of that page load; a new load shows it again.
|
||||||
|
|
||||||
Keep the seed job and the dialog together: any deployment that runs
|
Keep `database.seed` and the dialog together. A production deployment that seeds
|
||||||
`docker-compose.prod.seed.yaml` needs the disclosure in place to satisfy the
|
the catalogue without the disclosure does not satisfy the ethics documentation.
|
||||||
ethics documentation.
|
|
||||||
|
|
||||||
### 4. Database only (`docker-compose.db.yaml`)
|
### 4. Database only (`docker-compose.db.yaml`)
|
||||||
|
|
||||||
@@ -324,12 +247,6 @@ graph TD
|
|||||||
In development, `api.core` also depends on `seaweedfs`
|
In development, `api.core` also depends on `seaweedfs`
|
||||||
(`condition: service_started`, not a health check) alongside `database.seed`.
|
(`condition: service_started`, not a health check) alongside `database.seed`.
|
||||||
|
|
||||||
Production has a shorter chain, because the migration and seed jobs run outside
|
|
||||||
the stack: `api.core` waits for `seaweedfs` to pass its health check, and
|
|
||||||
`frontend` waits for `api.core` to report healthy. This ordering applies to
|
|
||||||
`docker compose up`; see [Restart tolerance](#restart-tolerance) for what happens
|
|
||||||
on a host reboot.
|
|
||||||
|
|
||||||
**Health Check Example** (SQL Server):
|
**Health Check Example** (SQL Server):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -372,6 +289,7 @@ api.core:
|
|||||||
|
|
||||||
- `biergartendata-prod` - Database files, external; survives `down -v`
|
- `biergartendata-prod` - Database files, external; survives `down -v`
|
||||||
- `seaweedfsdata-prod` - SeaweedFS object storage, external; survives `down -v`
|
- `seaweedfsdata-prod` - SeaweedFS object storage, external; survives `down -v`
|
||||||
|
- `nuget-cache-prod` - Production NuGet cache
|
||||||
|
|
||||||
See [Production data volumes](#production-data-volumes).
|
See [Production data volumes](#production-data-volumes).
|
||||||
|
|
||||||
@@ -394,11 +312,10 @@ stack down and leaves the database and the uploaded photos in place. Deleting
|
|||||||
them takes an explicit `docker volume rm`.
|
them takes an explicit `docker volume rm`.
|
||||||
|
|
||||||
The trade-off is that Compose creates neither one, and `up` fails with a clear
|
The trade-off is that Compose creates neither one, and `up` fails with a clear
|
||||||
error until both exist, hence the `docker volume create` step in
|
error until both exist — hence the `docker volume create` step in
|
||||||
[Deploy Production](#3-production-docker-composeprodyaml). External volumes also
|
[Deploy Production](#3-production-docker-composeprodyaml). External volumes also
|
||||||
carry their literal name rather than a `<project>_` prefix, so `docker volume ls`
|
carry their literal name rather than a `<project>_` prefix, which is why
|
||||||
shows `biergartendata-prod` and `seaweedfsdata-prod` unprefixed, unlike the
|
`docker volume ls` shows `biergartendata-prod` next to `web_nuget-cache-prod`.
|
||||||
`web_`-prefixed volumes of the dev and test stacks.
|
|
||||||
|
|
||||||
Both live under `/var/lib/docker/volumes`. To put them on a specific disk, point
|
Both live under `/var/lib/docker/volumes`. To put them on a specific disk, point
|
||||||
the daemon's `data-root` at it in `/etc/docker/daemon.json`, or mount the disk at
|
the daemon's `data-root` at it in `/etc/docker/daemon.json`, or mount the disk at
|
||||||
|
|||||||
@@ -260,25 +260,17 @@ and retrieving photos via an S3-compatible object store.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default
|
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333 # Required, no default
|
||||||
SEAWEEDFS_PUBLIC_URL=http://localhost:8333 # Optional, defaults to SEAWEEDFS_SERVICE_URL
|
|
||||||
SEAWEEDFS_ACCESS_KEY_ID=admin # Required, no default
|
SEAWEEDFS_ACCESS_KEY_ID=admin # Required, no default
|
||||||
SEAWEEDFS_SECRET_ACCESS_KEY=secret # Required, no default
|
SEAWEEDFS_SECRET_ACCESS_KEY=secret # Required, no default
|
||||||
SEAWEEDFS_BUCKET=my-bucket # Required, no default
|
SEAWEEDFS_BUCKET=my-bucket # Required, no default
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Implementation**: `S3FileStorageProvider` throws on construction if any of
|
- **Implementation**: `S3FileStorageProvider` throws on construction if any of
|
||||||
the four required values is missing
|
these four values is missing
|
||||||
- **Local dev**: point at the `seaweedfs` Docker service (S3 API on port 8333)
|
- **Local dev**: point at the `seaweedfs` Docker service (S3 API on port 8333)
|
||||||
- **Production**: point at any S3-compatible endpoint (AWS S3, a hosted
|
- **Production**: point at any S3-compatible endpoint (AWS S3, a hosted
|
||||||
SeaweedFS cluster, and so on); `ForcePathStyle` is always enabled, which
|
SeaweedFS cluster, and so on); `ForcePathStyle` is always enabled, which
|
||||||
SeaweedFS requires and AWS S3 also accepts
|
SeaweedFS requires and AWS S3 also accepts
|
||||||
- **`SEAWEEDFS_PUBLIC_URL`**: the endpoint download URLs are signed against,
|
|
||||||
used by `GetPresignedUrl` and reaching the API response as
|
|
||||||
`PublicUserProfileDto.AvatarUrl`. A presigned URL is signed over its host, so
|
|
||||||
this has to be the address the browser uses (`http://localhost:8333` with the
|
|
||||||
dev compose file, which publishes the S3 API port), separate from the
|
|
||||||
in-network `SEAWEEDFS_SERVICE_URL` the API uploads through. The two can be the
|
|
||||||
same value wherever one address serves both
|
|
||||||
|
|
||||||
## Docker-specific variables
|
## Docker-specific variables
|
||||||
|
|
||||||
@@ -325,7 +317,6 @@ from the template in place; edit them manually if you go that route.
|
|||||||
- `web/docker-compose.dev.yaml` → `web/.env.dev`
|
- `web/docker-compose.dev.yaml` → `web/.env.dev`
|
||||||
- `web/docker-compose.test.yaml` → `web/.env.test`
|
- `web/docker-compose.test.yaml` → `web/.env.test`
|
||||||
- `web/docker-compose.prod.yaml` → `web/.env.prod`
|
- `web/docker-compose.prod.yaml` → `web/.env.prod`
|
||||||
- `web/docker-compose.prod.seed.yaml` → `web/.env.prod`
|
|
||||||
|
|
||||||
## Variable reference table
|
## Variable reference table
|
||||||
|
|
||||||
@@ -349,7 +340,6 @@ from the template in place; edit them manually if you go that route.
|
|||||||
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
|
| `SMTP_FROM_EMAIL` | ✓ | | ✓ | Yes | Email sender address |
|
||||||
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
|
| `SMTP_FROM_NAME` | ✓ | | ✓ | No | Defaults to `The Biergarten` |
|
||||||
| `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL |
|
| `SEAWEEDFS_SERVICE_URL` | ✓ | | ✓ | Yes | S3-compatible endpoint URL |
|
||||||
| `SEAWEEDFS_PUBLIC_URL` | ✓ | | ✓ | No | Endpoint download URLs are signed against; defaults to `SEAWEEDFS_SERVICE_URL` |
|
|
||||||
| `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key |
|
| `SEAWEEDFS_ACCESS_KEY_ID` | ✓ | | ✓ | Yes | S3 access key |
|
||||||
| `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key |
|
| `SEAWEEDFS_SECRET_ACCESS_KEY` | ✓ | | ✓ | Yes | S3 secret key |
|
||||||
| `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name |
|
| `SEAWEEDFS_BUCKET` | ✓ | | ✓ | Yes | S3 bucket name |
|
||||||
@@ -424,7 +414,6 @@ SMTP_FROM_NAME=The Biergarten App
|
|||||||
|
|
||||||
# SeaweedFS (S3-compatible storage)
|
# SeaweedFS (S3-compatible storage)
|
||||||
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
|
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
|
||||||
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
|
|
||||||
SEAWEEDFS_ACCESS_KEY_ID=<generated-with-openssl>
|
SEAWEEDFS_ACCESS_KEY_ID=<generated-with-openssl>
|
||||||
SEAWEEDFS_SECRET_ACCESS_KEY=<generated-with-openssl>
|
SEAWEEDFS_SECRET_ACCESS_KEY=<generated-with-openssl>
|
||||||
SEAWEEDFS_BUCKET=my-bucket
|
SEAWEEDFS_BUCKET=my-bucket
|
||||||
|
|||||||
@@ -257,8 +257,7 @@ API.Specs/
|
|||||||
│ └── ApiGeneralSteps.cs # Shared/general step definitions
|
│ └── ApiGeneralSteps.cs # Shared/general step definitions
|
||||||
├── Mocks/
|
├── Mocks/
|
||||||
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
|
│ ├── MockEmailDispatcher.cs # Substitutes Features.Emails' IEmailDispatcher
|
||||||
│ ├── MockEmailProvider.cs # Substitutes Features.Emails' IEmailProvider
|
│ └── MockEmailProvider.cs # Substitutes Features.Emails' IEmailProvider
|
||||||
│ └── MockFileStorageProvider.cs # Substitutes Features.ImageUploads' IFileStorageProvider
|
|
||||||
└── TestApiFactory.cs # Test server setup (swaps in the mocks above)
|
└── TestApiFactory.cs # Test server setup (swaps in the mocks above)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -68,12 +68,8 @@ WEBSITE_BASE_URL=http://localhost:3000
|
|||||||
# ======================
|
# ======================
|
||||||
# SeaweedFS (S3) Configuration
|
# SeaweedFS (S3) Configuration
|
||||||
# ======================
|
# ======================
|
||||||
# Credentials and default bucket for the local SeaweedFS S3-compatible storage.
|
# Credentials and default bucket for the local SeaweedFS S3-compatible storage
|
||||||
# SEAWEEDFS_SERVICE_URL is the in-network endpoint the API uploads through;
|
|
||||||
# SEAWEEDFS_PUBLIC_URL is the endpoint download URLs are signed against, so it has to be
|
|
||||||
# reachable from the browser. It defaults to SEAWEEDFS_SERVICE_URL when left blank.
|
|
||||||
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
|
SEAWEEDFS_SERVICE_URL=http://seaweedfs:8333
|
||||||
SEAWEEDFS_PUBLIC_URL=http://localhost:8333
|
|
||||||
SEAWEEDFS_ACCESS_KEY_ID=admin
|
SEAWEEDFS_ACCESS_KEY_ID=admin
|
||||||
SEAWEEDFS_SECRET_ACCESS_KEY=secret
|
SEAWEEDFS_SECRET_ACCESS_KEY=secret
|
||||||
SEAWEEDFS_BUCKET=my-bucket
|
SEAWEEDFS_BUCKET=my-bucket
|
||||||
|
|||||||
@@ -13,61 +13,9 @@ Scenario: Public profile exposes only allowlisted fields
|
|||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
And the public profile response should match the registered account
|
And the public profile response should match the registered account
|
||||||
And the response JSON should have "biography" equal ""
|
And the response JSON should have "biography" equal ""
|
||||||
And the response JSON should have "avatarUrl" equal ""
|
|
||||||
And the response JSON should not contain "email"
|
And the response JSON should not contain "email"
|
||||||
And the response JSON should not contain "dateOfBirth"
|
And the response JSON should not contain "dateOfBirth"
|
||||||
|
|
||||||
Scenario: Public profile exposes the uploaded avatar
|
|
||||||
Given I have registered a new account
|
|
||||||
And I have a valid access token for my account
|
|
||||||
And I have uploaded an avatar
|
|
||||||
When I retrieve the public profile by ID
|
|
||||||
Then the response has HTTP status 200
|
|
||||||
And the public profile response should carry my current avatar
|
|
||||||
|
|
||||||
Scenario: Public profile exposes the current avatar after it is replaced
|
|
||||||
Given I have registered a new account
|
|
||||||
And I have a valid access token for my account
|
|
||||||
And I have uploaded an avatar
|
|
||||||
And I have uploaded a new avatar, replacing the previous one
|
|
||||||
When I retrieve the public profile by ID
|
|
||||||
Then the response has HTTP status 200
|
|
||||||
And the public profile response should carry my current avatar
|
|
||||||
And the public profile response should not carry my previous avatar
|
|
||||||
|
|
||||||
Scenario: Fetching the avatar without authentication succeeds
|
|
||||||
Given I have registered a new account
|
|
||||||
And I have a valid access token for my account
|
|
||||||
And I have uploaded an avatar
|
|
||||||
When I retrieve the public profile by ID without authentication
|
|
||||||
Then the response has HTTP status 200
|
|
||||||
And the public profile response should carry my current avatar
|
|
||||||
|
|
||||||
Scenario: A different user can read someone else's avatar
|
|
||||||
Given I have registered a new account
|
|
||||||
And I have a valid access token for my account
|
|
||||||
And I have uploaded an avatar
|
|
||||||
And I am logged in as a different user
|
|
||||||
When I retrieve the public profile by ID
|
|
||||||
Then the response has HTTP status 200
|
|
||||||
And the public profile response should carry my current avatar
|
|
||||||
|
|
||||||
Scenario: Listing user accounts exposes each account's avatar
|
|
||||||
Given I have registered a new account
|
|
||||||
And I have a valid access token for my account
|
|
||||||
And I have uploaded an avatar
|
|
||||||
When I list user accounts
|
|
||||||
Then the response has HTTP status 200
|
|
||||||
And my listed account should carry my current avatar
|
|
||||||
|
|
||||||
Scenario: Listing user accounts exposes each account's biography
|
|
||||||
Given I have registered a new account
|
|
||||||
And I have a valid access token for my account
|
|
||||||
And I have set my biography to "Drinks lagers."
|
|
||||||
When I list user accounts
|
|
||||||
Then the response has HTTP status 200
|
|
||||||
And my listed account should carry my biography
|
|
||||||
|
|
||||||
Scenario: Public profile for a non-existent account returns not found
|
Scenario: Public profile for a non-existent account returns not found
|
||||||
Given I have registered a new account
|
Given I have registered a new account
|
||||||
And I have a valid access token for my account
|
And I have a valid access token for my account
|
||||||
@@ -78,7 +26,6 @@ Scenario: Fetching the public profile without authentication succeeds
|
|||||||
Given I have registered a new account
|
Given I have registered a new account
|
||||||
When I retrieve the public profile by ID without authentication
|
When I retrieve the public profile by ID without authentication
|
||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
And the response JSON should have "avatarUrl" equal ""
|
|
||||||
And the response JSON should not contain "email"
|
And the response JSON should not contain "email"
|
||||||
And the response JSON should not contain "dateOfBirth"
|
And the response JSON should not contain "dateOfBirth"
|
||||||
|
|
||||||
@@ -103,7 +50,6 @@ Scenario: Listing user accounts as an authenticated user excludes private fields
|
|||||||
And I have a valid access token for my account
|
And I have a valid access token for my account
|
||||||
When I list user accounts
|
When I list user accounts
|
||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
And the response JSON should contain "avatarUrl"
|
|
||||||
And the response JSON should not contain "email"
|
And the response JSON should not contain "email"
|
||||||
And the response JSON should not contain "dateOfBirth"
|
And the response JSON should not contain "dateOfBirth"
|
||||||
|
|
||||||
@@ -114,14 +60,6 @@ Scenario: The caller can fetch their own account
|
|||||||
Then the response has HTTP status 200
|
Then the response has HTTP status 200
|
||||||
And the public profile response should match the registered account
|
And the public profile response should match the registered account
|
||||||
|
|
||||||
Scenario: The caller's own account omits the avatar graph
|
|
||||||
Given I have registered a new account
|
|
||||||
And I have a valid access token for my account
|
|
||||||
And I have uploaded an avatar
|
|
||||||
When I retrieve my own account
|
|
||||||
Then the response has HTTP status 200
|
|
||||||
And the response JSON should not contain "userAvatar"
|
|
||||||
|
|
||||||
Scenario: The account endpoint serves the caller named by the access token
|
Scenario: The account endpoint serves the caller named by the access token
|
||||||
Given I have registered a new account
|
Given I have registered a new account
|
||||||
And I am logged in as a different user
|
And I am logged in as a different user
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
using Features.ImageUploads.Services;
|
|
||||||
|
|
||||||
namespace API.Specs.Mocks;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Test double for <see cref="IFileStorageProvider" /> that records uploads in memory, so specs can
|
|
||||||
/// exercise photo-backed features without object storage.
|
|
||||||
/// </summary>
|
|
||||||
public class MockFileStorageProvider : IFileStorageProvider
|
|
||||||
{
|
|
||||||
private readonly List<string> _keys = [];
|
|
||||||
private readonly Dictionary<string, byte[]> _objects = new();
|
|
||||||
|
|
||||||
public IReadOnlyCollection<string> UploadedKeys => _keys.ToList();
|
|
||||||
|
|
||||||
/// <summary>The key of the most recent upload.</summary>
|
|
||||||
/// <exception cref="InvalidOperationException">Thrown when nothing has been uploaded yet.</exception>
|
|
||||||
public string LastUploadedKey =>
|
|
||||||
_keys.Count > 0
|
|
||||||
? _keys[^1]
|
|
||||||
: throw new InvalidOperationException("no file has been uploaded");
|
|
||||||
|
|
||||||
public Task UploadAsync(
|
|
||||||
string key,
|
|
||||||
Stream content,
|
|
||||||
string contentType,
|
|
||||||
CancellationToken cancellationToken = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
using MemoryStream buffer = new();
|
|
||||||
content.CopyTo(buffer);
|
|
||||||
|
|
||||||
if (!_objects.ContainsKey(key))
|
|
||||||
_keys.Add(key);
|
|
||||||
|
|
||||||
_objects[key] = buffer.ToArray();
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task DeleteAsync(string key, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
_objects.Remove(key);
|
|
||||||
_keys.Remove(key);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Stands in for a signed download URL: an absolute URL on a host the object store would serve,
|
|
||||||
/// carrying the key in its path, so specs can assert the shape callers actually receive.
|
|
||||||
/// </summary>
|
|
||||||
public string GetPresignedUrl(string key, TimeSpan expiresIn)
|
|
||||||
{
|
|
||||||
return $"https://storage.test/{key}?X-Amz-Expires={(int)expiresIn.TotalSeconds}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -183,25 +183,6 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
AssertFieldAbsent(payloadElem, field);
|
AssertFieldAbsent(payloadElem, field);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Then("the response JSON should contain {string}")]
|
|
||||||
public void ThenTheResponseJsonShouldContainString(string field)
|
|
||||||
{
|
|
||||||
scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
|
||||||
|
|
||||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
|
||||||
JsonElement root = doc.RootElement;
|
|
||||||
|
|
||||||
if (root.ValueKind == JsonValueKind.Object &&
|
|
||||||
!root.TryGetProperty(field, out _) &&
|
|
||||||
root.TryGetProperty("payload", out JsonElement payloadElem))
|
|
||||||
{
|
|
||||||
AssertFieldPresent(payloadElem, field);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
AssertFieldPresent(root, field);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Recurses into arrays so a listing endpoint's response can be asserted the same way as a
|
/// Recurses into arrays so a listing endpoint's response can be asserted the same way as a
|
||||||
/// single-resource response.
|
/// single-resource response.
|
||||||
@@ -222,31 +203,4 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Recurses into arrays so every item of a listing endpoint's response must carry the field.
|
|
||||||
/// </summary>
|
|
||||||
private static void AssertFieldPresent(JsonElement element, string field)
|
|
||||||
{
|
|
||||||
switch (element.ValueKind)
|
|
||||||
{
|
|
||||||
case JsonValueKind.Object:
|
|
||||||
element
|
|
||||||
.TryGetProperty(field, out _)
|
|
||||||
.Should()
|
|
||||||
.BeTrue("Expected field '{0}' to be present in the response", field);
|
|
||||||
break;
|
|
||||||
case JsonValueKind.Array:
|
|
||||||
element
|
|
||||||
.EnumerateArray()
|
|
||||||
.Should()
|
|
||||||
.NotBeEmpty(
|
|
||||||
"Expected at least one item to assert field '{0}' against",
|
|
||||||
field
|
|
||||||
);
|
|
||||||
foreach (JsonElement item in element.EnumerateArray())
|
|
||||||
AssertFieldPresent(item, field);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using API.Specs.Mocks;
|
|
||||||
using Features.ImageUploads.Services;
|
|
||||||
using Features.Users.Commands.Profile.UploadAvatar;
|
|
||||||
using Features.Users.Repository;
|
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Reqnroll;
|
using Reqnroll;
|
||||||
|
|
||||||
namespace API.Specs.Steps;
|
namespace API.Specs.Steps;
|
||||||
@@ -17,11 +10,6 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
|||||||
private const string AccessTokenKey = "accessToken";
|
private const string AccessTokenKey = "accessToken";
|
||||||
private const string RegisteredUserIdKey = "registeredUserId";
|
private const string RegisteredUserIdKey = "registeredUserId";
|
||||||
private const string RegisteredUsernameKey = "registeredUsername";
|
private const string RegisteredUsernameKey = "registeredUsername";
|
||||||
private const string CurrentAvatarKeyKey = "currentAvatarKey";
|
|
||||||
private const string PreviousAvatarKeyKey = "previousAvatarKey";
|
|
||||||
private const string BiographyKey = "biography";
|
|
||||||
|
|
||||||
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
|
||||||
|
|
||||||
private async Task SendAsync(HttpRequestMessage requestMessage)
|
private async Task SendAsync(HttpRequestMessage requestMessage)
|
||||||
{
|
{
|
||||||
@@ -48,193 +36,22 @@ public class UserSteps(ScenarioContext scenario) : ApiStepsBase(scenario)
|
|||||||
: throw new InvalidOperationException("registered user ID not found in scenario");
|
: throw new InvalidOperationException("registered user ID not found in scenario");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the smallest upload the photo validator accepts: a PNG signature padded out to the
|
|
||||||
/// 1 KiB minimum.
|
|
||||||
/// </summary>
|
|
||||||
private static IFormFile CreateAvatarFile()
|
|
||||||
{
|
|
||||||
byte[] content = new byte[2048];
|
|
||||||
PngSignature.CopyTo(content, 0);
|
|
||||||
MemoryStream stream = new(content);
|
|
||||||
|
|
||||||
return new FormFile(stream, 0, content.Length, "file", "avatar.png")
|
|
||||||
{
|
|
||||||
Headers = new HeaderDictionary { ["Content-Type"] = "image/png" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Uploads an avatar and records the object key the storage provider actually received, rather
|
|
||||||
/// than re-deriving the key template the handler uses.
|
|
||||||
/// </summary>
|
|
||||||
private async Task UploadAvatarAsync()
|
|
||||||
{
|
|
||||||
Guid userId = GetRegisteredUserId();
|
|
||||||
|
|
||||||
using IServiceScope scope = GetFactory().Services.CreateScope();
|
|
||||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
|
||||||
await mediator.Send(new UploadAvatarCommand(userId, CreateAvatarFile()));
|
|
||||||
|
|
||||||
if (Scenario.TryGetValue<string>(CurrentAvatarKeyKey, out string? existing))
|
|
||||||
Scenario[PreviousAvatarKeyKey] = existing;
|
|
||||||
|
|
||||||
Scenario[CurrentAvatarKeyKey] = GetStorageProvider().LastUploadedKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
private MockFileStorageProvider GetStorageProvider()
|
|
||||||
{
|
|
||||||
return (MockFileStorageProvider)
|
|
||||||
GetFactory().Services.GetRequiredService<IFileStorageProvider>();
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetCurrentAvatarKey()
|
|
||||||
{
|
|
||||||
return Scenario.TryGetValue(CurrentAvatarKeyKey, out string? key) && key is not null
|
|
||||||
? key
|
|
||||||
: throw new InvalidOperationException("no avatar has been uploaded in this scenario");
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetPreviousAvatarKey()
|
|
||||||
{
|
|
||||||
return Scenario.TryGetValue(PreviousAvatarKeyKey, out string? key) && key is not null
|
|
||||||
? key
|
|
||||||
: throw new InvalidOperationException(
|
|
||||||
"only one avatar has been uploaded in this scenario"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Asserts that <paramref name="avatarUrl" /> is something a browser can fetch -- an absolute
|
|
||||||
/// HTTP(S) URL -- pointing at the object stored under <paramref name="expectedKey" />. A bare
|
|
||||||
/// storage key would resolve against the page's own origin, so the shape is the contract here,
|
|
||||||
/// not just the value.
|
|
||||||
/// </summary>
|
|
||||||
private static Uri AssertFetchableUrl(string? avatarUrl, string expectedKey)
|
|
||||||
{
|
|
||||||
avatarUrl.Should().NotBeNullOrWhiteSpace();
|
|
||||||
|
|
||||||
Uri.TryCreate(avatarUrl, UriKind.Absolute, out Uri? uri)
|
|
||||||
.Should()
|
|
||||||
.BeTrue("avatarUrl must be an absolute URL, but was '{0}'", avatarUrl);
|
|
||||||
uri!.Scheme.Should().BeOneOf("http", "https");
|
|
||||||
uri.AbsolutePath.Should().EndWith(expectedKey, "the URL must address the uploaded object");
|
|
||||||
|
|
||||||
return uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetAvatarUrlFromResponse()
|
|
||||||
{
|
|
||||||
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
|
||||||
|
|
||||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
|
||||||
return doc.RootElement.GetProperty("avatarUrl").GetString()!;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Given("I have uploaded an avatar")]
|
|
||||||
public async Task GivenIHaveUploadedAnAvatar()
|
|
||||||
{
|
|
||||||
await UploadAvatarAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Given("I have uploaded a new avatar, replacing the previous one")]
|
|
||||||
public async Task GivenIHaveUploadedANewAvatarReplacingThePreviousOne()
|
|
||||||
{
|
|
||||||
await UploadAvatarAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Writes the biography straight through the repository: registration creates the profile row
|
|
||||||
/// with an empty biography, and no endpoint edits it yet.
|
|
||||||
/// </summary>
|
|
||||||
[Given("I have set my biography to {string}")]
|
|
||||||
public async Task GivenIHaveSetMyBiographyTo(string biography)
|
|
||||||
{
|
|
||||||
using IServiceScope scope = GetFactory().Services.CreateScope();
|
|
||||||
IUserProfileRepository profiles =
|
|
||||||
scope.ServiceProvider.GetRequiredService<IUserProfileRepository>();
|
|
||||||
|
|
||||||
await profiles.UpdateBiographyAsync(
|
|
||||||
GetRegisteredUserId(),
|
|
||||||
biography,
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
|
|
||||||
Scenario[BiographyKey] = biography;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Then("the public profile response should carry my current avatar")]
|
|
||||||
public void ThenThePublicProfileResponseShouldCarryMyCurrentAvatar()
|
|
||||||
{
|
|
||||||
AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Then("the public profile response should not carry my previous avatar")]
|
|
||||||
public void ThenThePublicProfileResponseShouldNotCarryMyPreviousAvatar()
|
|
||||||
{
|
|
||||||
Uri uri = AssertFetchableUrl(GetAvatarUrlFromResponse(), GetCurrentAvatarKey());
|
|
||||||
uri.AbsolutePath.Should().NotContain(GetPreviousAvatarKey());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Then("my listed account should carry my current avatar")]
|
|
||||||
public void ThenMyListedAccountShouldCarryMyCurrentAvatar()
|
|
||||||
{
|
|
||||||
AssertFetchableUrl(
|
|
||||||
GetMyListedAccount().GetProperty("avatarUrl").GetString(),
|
|
||||||
GetCurrentAvatarKey()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Then("my listed account should carry my biography")]
|
|
||||||
public void ThenMyListedAccountShouldCarryMyBiography()
|
|
||||||
{
|
|
||||||
string expected =
|
|
||||||
Scenario.TryGetValue(BiographyKey, out string? biography) && biography is not null
|
|
||||||
? biography
|
|
||||||
: throw new InvalidOperationException("no biography was set in this scenario");
|
|
||||||
|
|
||||||
GetMyListedAccount().GetProperty("biography").GetString().Should().Be(expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Finds the registered account in a listing response.</summary>
|
|
||||||
private JsonElement GetMyListedAccount()
|
|
||||||
{
|
|
||||||
Scenario.TryGetValue<string>(ResponseBodyKey, out string? responseBody).Should().BeTrue();
|
|
||||||
|
|
||||||
using JsonDocument doc = JsonDocument.Parse(responseBody!);
|
|
||||||
doc.RootElement.ValueKind.Should().Be(JsonValueKind.Array);
|
|
||||||
|
|
||||||
return doc
|
|
||||||
.RootElement.EnumerateArray()
|
|
||||||
.Should()
|
|
||||||
.ContainSingle(item =>
|
|
||||||
item.GetProperty("userAccountId").GetGuid() == GetRegisteredUserId()
|
|
||||||
)
|
|
||||||
.Subject.Clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
[When("I retrieve the public profile by ID")]
|
[When("I retrieve the public profile by ID")]
|
||||||
public async Task WhenIRetrieveThePublicProfileById()
|
public async Task WhenIRetrieveThePublicProfileById()
|
||||||
{
|
{
|
||||||
await SendAsync(
|
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile"));
|
||||||
NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I retrieve the public profile by ID without authentication")]
|
[When("I retrieve the public profile by ID without authentication")]
|
||||||
public async Task WhenIRetrieveThePublicProfileByIdWithoutAuthentication()
|
public async Task WhenIRetrieveThePublicProfileByIdWithoutAuthentication()
|
||||||
{
|
{
|
||||||
await SendAsync(
|
await SendAsync(new HttpRequestMessage(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile"));
|
||||||
new HttpRequestMessage(HttpMethod.Get, $"/api/user/{GetRegisteredUserId()}/profile")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I retrieve the public profile by a non-existent ID")]
|
[When("I retrieve the public profile by a non-existent ID")]
|
||||||
public async Task WhenIRetrieveThePublicProfileByANonExistentId()
|
public async Task WhenIRetrieveThePublicProfileByANonExistentId()
|
||||||
{
|
{
|
||||||
await SendAsync(
|
await SendAsync(NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile"));
|
||||||
NewAuthenticatedRequest(HttpMethod.Get, $"/api/user/{Guid.NewGuid()}/profile")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("I retrieve my own account without authentication")]
|
[When("I retrieve my own account without authentication")]
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using API.Core;
|
using API.Core;
|
||||||
using API.Specs.Mocks;
|
using API.Specs.Mocks;
|
||||||
using Features.Emails.Services;
|
using Features.Emails.Services;
|
||||||
using Features.ImageUploads.Services;
|
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -33,15 +32,6 @@ internal class TestApiFactory : WebApplicationFactory<Program>
|
|||||||
services.Remove(emailDispatcherDescriptor);
|
services.Remove(emailDispatcherDescriptor);
|
||||||
|
|
||||||
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
|
services.AddScoped<IEmailDispatcher, MockEmailDispatcher>();
|
||||||
|
|
||||||
ServiceDescriptor? fileStorageDescriptor = services.SingleOrDefault(d =>
|
|
||||||
d.ServiceType == typeof(IFileStorageProvider)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fileStorageDescriptor != null)
|
|
||||||
services.Remove(fileStorageDescriptor);
|
|
||||||
|
|
||||||
services.AddSingleton<IFileStorageProvider, MockFileStorageProvider>();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,6 @@ public static class ConfigurationKeys
|
|||||||
public const string SmtpFromName = "SMTP_FROM_NAME";
|
public const string SmtpFromName = "SMTP_FROM_NAME";
|
||||||
|
|
||||||
public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL";
|
public const string SeaweedFsServiceUrl = "SEAWEEDFS_SERVICE_URL";
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The browser-reachable base URL of the object store, used to sign download URLs. Falls back to
|
|
||||||
/// <see cref="SeaweedFsServiceUrl" /> when unset: SigV4 covers the host, so a URL signed against
|
|
||||||
/// the container-internal endpoint is only usable by callers that can resolve that host.
|
|
||||||
/// </summary>
|
|
||||||
public const string SeaweedFsPublicUrl = "SEAWEEDFS_PUBLIC_URL";
|
|
||||||
|
|
||||||
public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID";
|
public const string SeaweedFsAccessKeyId = "SEAWEEDFS_ACCESS_KEY_ID";
|
||||||
public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY";
|
public const string SeaweedFsSecretAccessKey = "SEAWEEDFS_SECRET_ACCESS_KEY";
|
||||||
public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET";
|
public const string SeaweedFsBucket = "SEAWEEDFS_BUCKET";
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Domain.Entities;
|
namespace Domain.Entities;
|
||||||
|
|
||||||
public sealed class UserAccount
|
public sealed class UserAccount
|
||||||
@@ -22,10 +20,4 @@ public sealed class UserAccount
|
|||||||
// Navigation properties
|
// Navigation properties
|
||||||
public UserCredential? UserCredential { get; set; }
|
public UserCredential? UserCredential { get; set; }
|
||||||
public UserProfile? UserProfile { get; set; }
|
public UserProfile? UserProfile { get; set; }
|
||||||
|
|
||||||
// GET /api/user/authenticated serializes this entity directly, and the avatar graph carries the
|
|
||||||
// storage key and uploader of the underlying Photo. Clients read the avatar from
|
|
||||||
// PublicUserProfileDto.AvatarUrl, which is a signed URL, so keep this one out of the response.
|
|
||||||
[JsonIgnore]
|
|
||||||
public UserAvatar? UserAvatar { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,14 +13,11 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
|
|||||||
{
|
{
|
||||||
private readonly string _bucket;
|
private readonly string _bucket;
|
||||||
private readonly IAmazonS3 _client;
|
private readonly IAmazonS3 _client;
|
||||||
private readonly IAmazonS3 _presignClient;
|
|
||||||
private readonly Protocol _presignProtocol;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="S3FileStorageProvider" /> class, reading storage
|
/// Initializes a new instance of the <see cref="S3FileStorageProvider" /> class, reading storage
|
||||||
/// configuration (<c>SEAWEEDFS_SERVICE_URL</c>, <c>SEAWEEDFS_ACCESS_KEY_ID</c>,
|
/// configuration (<c>SEAWEEDFS_SERVICE_URL</c>, <c>SEAWEEDFS_ACCESS_KEY_ID</c>,
|
||||||
/// <c>SEAWEEDFS_SECRET_ACCESS_KEY</c>, <c>SEAWEEDFS_BUCKET</c>, and the optional
|
/// <c>SEAWEEDFS_SECRET_ACCESS_KEY</c>, <c>SEAWEEDFS_BUCKET</c>) from <paramref name="configuration" />.
|
||||||
/// <c>SEAWEEDFS_PUBLIC_URL</c>) from <paramref name="configuration" />.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <exception cref="InvalidOperationException">
|
||||||
/// Thrown when any of the required configuration values is not set.
|
/// Thrown when any of the required configuration values is not set.
|
||||||
@@ -48,31 +45,6 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
|
|||||||
AmazonS3Config config = new() { ServiceURL = serviceUrl, ForcePathStyle = true };
|
AmazonS3Config config = new() { ServiceURL = serviceUrl, ForcePathStyle = true };
|
||||||
|
|
||||||
_client = new AmazonS3Client(accessKeyId, secretAccessKey, config);
|
_client = new AmazonS3Client(accessKeyId, secretAccessKey, config);
|
||||||
|
|
||||||
// A presigned URL is signed over its host, so one signed against the container-internal
|
|
||||||
// endpoint is only usable from inside the network. Sign downloads against the public
|
|
||||||
// endpoint instead, so a browser can fetch the object.
|
|
||||||
string? configuredPublicUrl = configuration[ConfigurationKeys.SeaweedFsPublicUrl];
|
|
||||||
string publicUrl = string.IsNullOrWhiteSpace(configuredPublicUrl)
|
|
||||||
? serviceUrl
|
|
||||||
: configuredPublicUrl;
|
|
||||||
|
|
||||||
_presignClient =
|
|
||||||
publicUrl == serviceUrl
|
|
||||||
? _client
|
|
||||||
: new AmazonS3Client(
|
|
||||||
accessKeyId,
|
|
||||||
secretAccessKey,
|
|
||||||
new AmazonS3Config { ServiceURL = publicUrl, ForcePathStyle = true }
|
|
||||||
);
|
|
||||||
|
|
||||||
// GetPreSignedUrlRequest.Protocol defaults to HTTPS and ignores the ServiceURL scheme, so
|
|
||||||
// take the scheme from the endpoint the URL is signed against.
|
|
||||||
_presignProtocol =
|
|
||||||
Uri.TryCreate(publicUrl, UriKind.Absolute, out Uri? publicUri)
|
|
||||||
&& publicUri.Scheme == Uri.UriSchemeHttp
|
|
||||||
? Protocol.HTTP
|
|
||||||
: Protocol.HTTPS;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -110,18 +82,14 @@ public class S3FileStorageProvider : IFileStorageProvider, IDisposable
|
|||||||
BucketName = _bucket,
|
BucketName = _bucket,
|
||||||
Key = key,
|
Key = key,
|
||||||
Expires = DateTime.UtcNow.Add(expiresIn),
|
Expires = DateTime.UtcNow.Add(expiresIn),
|
||||||
Protocol = _presignProtocol,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return _presignClient.GetPreSignedURL(request);
|
return _client.GetPreSignedURL(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (!ReferenceEquals(_presignClient, _client))
|
|
||||||
_presignClient.Dispose();
|
|
||||||
|
|
||||||
_client.Dispose();
|
_client.Dispose();
|
||||||
GC.SuppressFinalize(this);
|
GC.SuppressFinalize(this);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Features.ImageUploads.Services;
|
|
||||||
using Features.Users.Dtos;
|
using Features.Users.Dtos;
|
||||||
using Features.Users.Queries.GetAllUsers;
|
using Features.Users.Queries.GetAllUsers;
|
||||||
using Features.Users.Repository;
|
using Features.Users.Repository;
|
||||||
@@ -10,36 +9,27 @@ namespace Features.Users.Tests.Queries;
|
|||||||
|
|
||||||
public class GetAllUsersHandlerTests
|
public class GetAllUsersHandlerTests
|
||||||
{
|
{
|
||||||
private readonly Mock<IUserListRepository> _repoMock = new();
|
|
||||||
private readonly Mock<IFileStorageProvider> _storageMock = new();
|
|
||||||
private readonly GetAllUsersHandler _handler;
|
|
||||||
|
|
||||||
public GetAllUsersHandlerTests()
|
|
||||||
{
|
|
||||||
_storageMock
|
|
||||||
.Setup(s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()))
|
|
||||||
.Returns((string key, TimeSpan _) => $"https://storage.test/{key}?signed");
|
|
||||||
|
|
||||||
_handler = new GetAllUsersHandler(_repoMock.Object, _storageMock.Object);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_PassesLimitAndOffset_ToRepository()
|
public async Task Handle_PassesLimitAndOffset_ToRepository()
|
||||||
{
|
{
|
||||||
_repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
|
Mock<IUserListRepository> repoMock = new();
|
||||||
|
GetAllUsersHandler handler = new(repoMock.Object);
|
||||||
|
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());
|
||||||
|
|
||||||
IEnumerable<PublicUserProfileDto> result = await _handler.Handle(
|
IEnumerable<PublicUserProfileDto> result = await handler.Handle(
|
||||||
new GetAllUsersQuery(10, 5),
|
new GetAllUsersQuery(10, 5),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
|
|
||||||
result.Should().BeEmpty();
|
result.Should().BeEmpty();
|
||||||
_repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
repoMock.Verify(r => r.GetAllAsync(10, 5), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_MapsUsersToPublicProfileDtos_AndExcludesPrivateFields()
|
public async Task Handle_MapsUsersToPublicProfileDtos_AndExcludesPrivateFields()
|
||||||
{
|
{
|
||||||
|
Mock<IUserListRepository> repoMock = new();
|
||||||
|
GetAllUsersHandler handler = new(repoMock.Object);
|
||||||
UserAccount user = new()
|
UserAccount user = new()
|
||||||
{
|
{
|
||||||
UserAccountId = Guid.NewGuid(),
|
UserAccountId = Guid.NewGuid(),
|
||||||
@@ -48,60 +38,13 @@ public class GetAllUsersHandlerTests
|
|||||||
LastName = "One",
|
LastName = "One",
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
_repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([user]);
|
repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([user]);
|
||||||
|
|
||||||
IEnumerable<PublicUserProfileDto> result = await _handler.Handle(
|
IEnumerable<PublicUserProfileDto> result = await handler.Handle(
|
||||||
new GetAllUsersQuery(null, null),
|
new GetAllUsersQuery(null, null),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
|
|
||||||
result
|
result.Should().ContainSingle().Which.Should().BeEquivalentTo(user.ToPublicProfileDto());
|
||||||
.Should()
|
|
||||||
.ContainSingle()
|
|
||||||
.Which.Should()
|
|
||||||
.BeEquivalentTo(user.ToPublicProfileDto(_storageMock.Object));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_MapsEachUsersOwnAvatarUrl()
|
|
||||||
{
|
|
||||||
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
|
|
||||||
UserAccount withAvatar = new()
|
|
||||||
{
|
|
||||||
UserAccountId = Guid.NewGuid(),
|
|
||||||
Username = "has-avatar",
|
|
||||||
FirstName = "Has",
|
|
||||||
LastName = "Avatar",
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
UserProfile = new UserProfile { Biography = "Drinks lagers." },
|
|
||||||
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
|
|
||||||
};
|
|
||||||
UserAccount withoutAvatar = new()
|
|
||||||
{
|
|
||||||
UserAccountId = Guid.NewGuid(),
|
|
||||||
Username = "no-avatar",
|
|
||||||
FirstName = "No",
|
|
||||||
LastName = "Avatar",
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
_repoMock.Setup(r => r.GetAllAsync(null, null)).ReturnsAsync([withAvatar, withoutAvatar]);
|
|
||||||
|
|
||||||
IEnumerable<PublicUserProfileDto> result = await _handler.Handle(
|
|
||||||
new GetAllUsersQuery(null, null),
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
|
|
||||||
result
|
|
||||||
.Select(dto => dto.AvatarUrl)
|
|
||||||
.Should()
|
|
||||||
.Equal($"https://storage.test/{avatarKey}?signed", string.Empty);
|
|
||||||
_storageMock.Verify(
|
|
||||||
s => s.GetPresignedUrl(avatarKey, UserDtoMapper.AvatarUrlLifetime),
|
|
||||||
Times.Once
|
|
||||||
);
|
|
||||||
_storageMock.Verify(
|
|
||||||
s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()),
|
|
||||||
Times.Once
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Features.ImageUploads.Services;
|
|
||||||
using Features.Users.Dtos;
|
using Features.Users.Dtos;
|
||||||
using Features.Users.Queries.GetPublicUserProfileById;
|
using Features.Users.Queries.GetPublicUserProfileById;
|
||||||
using Features.Users.Repository;
|
using Features.Users.Repository;
|
||||||
@@ -13,21 +12,15 @@ public class GetPublicUserProfileByIdHandlerTests
|
|||||||
{
|
{
|
||||||
private readonly GetPublicUserProfileByIdHandler _handler;
|
private readonly GetPublicUserProfileByIdHandler _handler;
|
||||||
private readonly Mock<IUserListRepository> _repoMock = new();
|
private readonly Mock<IUserListRepository> _repoMock = new();
|
||||||
private readonly Mock<IFileStorageProvider> _storageMock = new();
|
|
||||||
|
|
||||||
public GetPublicUserProfileByIdHandlerTests()
|
public GetPublicUserProfileByIdHandlerTests()
|
||||||
{
|
{
|
||||||
_storageMock
|
_handler = new GetPublicUserProfileByIdHandler(_repoMock.Object);
|
||||||
.Setup(s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()))
|
|
||||||
.Returns((string key, TimeSpan _) => $"https://storage.test/{key}?signed");
|
|
||||||
|
|
||||||
_handler = new GetPublicUserProfileByIdHandler(_repoMock.Object, _storageMock.Object);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_ReturnsPublicProfile_WhenFound()
|
public async Task Handle_ReturnsPublicProfile_WhenFound()
|
||||||
{
|
{
|
||||||
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
|
|
||||||
UserAccount user = new()
|
UserAccount user = new()
|
||||||
{
|
{
|
||||||
UserAccountId = Guid.NewGuid(),
|
UserAccountId = Guid.NewGuid(),
|
||||||
@@ -38,7 +31,6 @@ public class GetPublicUserProfileByIdHandlerTests
|
|||||||
DateOfBirth = new DateTime(1990, 1, 1),
|
DateOfBirth = new DateTime(1990, 1, 1),
|
||||||
CreatedAt = new DateTime(2024, 1, 1),
|
CreatedAt = new DateTime(2024, 1, 1),
|
||||||
UserProfile = new UserProfile { Biography = "Drinks lagers." },
|
UserProfile = new UserProfile { Biography = "Drinks lagers." },
|
||||||
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
|
|
||||||
};
|
};
|
||||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||||
|
|
||||||
@@ -56,48 +48,13 @@ public class GetPublicUserProfileByIdHandlerTests
|
|||||||
user.FirstName,
|
user.FirstName,
|
||||||
user.LastName,
|
user.LastName,
|
||||||
"Drinks lagers.",
|
"Drinks lagers.",
|
||||||
$"https://storage.test/{avatarKey}?signed",
|
|
||||||
user.CreatedAt
|
user.CreatedAt
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <c>Photo.Hyperlink</c> holds an object key, so the DTO has to carry a signed URL rather than
|
|
||||||
/// the stored value: a bare key resolves against the calling page's own origin.
|
|
||||||
/// </summary>
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_SignsTheAvatarKey_RatherThanReturningIt()
|
public async Task Handle_ReturnsEmptyBiography_WhenProfileMissing()
|
||||||
{
|
|
||||||
string avatarKey = $"avatars/{Guid.NewGuid()}/{Guid.NewGuid()}";
|
|
||||||
UserAccount user = new()
|
|
||||||
{
|
|
||||||
UserAccountId = Guid.NewGuid(),
|
|
||||||
Username = "test",
|
|
||||||
FirstName = "Jane",
|
|
||||||
LastName = "Doe",
|
|
||||||
CreatedAt = new DateTime(2024, 1, 1),
|
|
||||||
UserAvatar = new UserAvatar { Photo = new Photo { Hyperlink = avatarKey } },
|
|
||||||
};
|
|
||||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
|
||||||
|
|
||||||
PublicUserProfileDto result = await _handler.Handle(
|
|
||||||
new GetPublicUserProfileByIdQuery(user.UserAccountId),
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
|
|
||||||
result.AvatarUrl.Should().NotBe(avatarKey);
|
|
||||||
Uri.TryCreate(result.AvatarUrl, UriKind.Absolute, out Uri? uri).Should().BeTrue();
|
|
||||||
uri!.Scheme.Should().BeOneOf("http", "https");
|
|
||||||
uri.AbsolutePath.Should().EndWith(avatarKey);
|
|
||||||
_storageMock.Verify(
|
|
||||||
s => s.GetPresignedUrl(avatarKey, UserDtoMapper.AvatarUrlLifetime),
|
|
||||||
Times.Once
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_ReturnsEmptyBiographyAndAvatarUrl_WhenProfileMissing()
|
|
||||||
{
|
{
|
||||||
UserAccount user = new()
|
UserAccount user = new()
|
||||||
{
|
{
|
||||||
@@ -115,57 +72,6 @@ public class GetPublicUserProfileByIdHandlerTests
|
|||||||
);
|
);
|
||||||
|
|
||||||
result.Biography.Should().BeEmpty();
|
result.Biography.Should().BeEmpty();
|
||||||
result.AvatarUrl.Should().BeEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_ReturnsEmptyAvatarUrl_WhenProfileHasNoAvatar()
|
|
||||||
{
|
|
||||||
UserAccount user = new()
|
|
||||||
{
|
|
||||||
UserAccountId = Guid.NewGuid(),
|
|
||||||
Username = "test",
|
|
||||||
FirstName = "Jane",
|
|
||||||
LastName = "Doe",
|
|
||||||
CreatedAt = new DateTime(2024, 1, 1),
|
|
||||||
UserProfile = new UserProfile { Biography = "Drinks lagers." },
|
|
||||||
};
|
|
||||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
|
||||||
|
|
||||||
PublicUserProfileDto result = await _handler.Handle(
|
|
||||||
new GetPublicUserProfileByIdQuery(user.UserAccountId),
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
|
|
||||||
result.Biography.Should().Be("Drinks lagers.");
|
|
||||||
result.AvatarUrl.Should().BeEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Handle_ReturnsEmptyAvatarUrl_WhenAvatarHasNoPhoto()
|
|
||||||
{
|
|
||||||
UserAccount user = new()
|
|
||||||
{
|
|
||||||
UserAccountId = Guid.NewGuid(),
|
|
||||||
Username = "test",
|
|
||||||
FirstName = "Jane",
|
|
||||||
LastName = "Doe",
|
|
||||||
CreatedAt = new DateTime(2024, 1, 1),
|
|
||||||
UserProfile = new UserProfile { Biography = "Drinks lagers." },
|
|
||||||
UserAvatar = new UserAvatar(),
|
|
||||||
};
|
|
||||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
|
||||||
|
|
||||||
PublicUserProfileDto result = await _handler.Handle(
|
|
||||||
new GetPublicUserProfileByIdQuery(user.UserAccountId),
|
|
||||||
CancellationToken.None
|
|
||||||
);
|
|
||||||
|
|
||||||
result.AvatarUrl.Should().BeEmpty();
|
|
||||||
_storageMock.Verify(
|
|
||||||
s => s.GetPresignedUrl(It.IsAny<string>(), It.IsAny<TimeSpan>()),
|
|
||||||
Times.Never
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1,39 +1,19 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Features.ImageUploads.Services;
|
|
||||||
|
|
||||||
namespace Features.Users.Dtos;
|
namespace Features.Users.Dtos;
|
||||||
|
|
||||||
/// <summary>Converts user domain entities into API response models.</summary>
|
/// <summary>Converts user domain entities into API response models.</summary>
|
||||||
public static class UserDtoMapper
|
public static class UserDtoMapper
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// How long a presigned avatar URL stays valid. Long enough to outlive the page that embeds it,
|
|
||||||
/// short enough that a leaked URL stops working.
|
|
||||||
/// </summary>
|
|
||||||
public static readonly TimeSpan AvatarUrlLifetime = TimeSpan.FromHours(1);
|
|
||||||
|
|
||||||
/// <summary>Converts a user account into its public-profile representation.</summary>
|
/// <summary>Converts a user account into its public-profile representation.</summary>
|
||||||
/// <param name="user">The account to convert.</param>
|
public static PublicUserProfileDto ToPublicProfileDto(this UserAccount user)
|
||||||
/// <param name="fileStorageProvider">
|
|
||||||
/// Signs the download URL for the account's avatar. <c>Photo.Hyperlink</c> holds an object key
|
|
||||||
/// rather than a URL, so it has to be signed before a client can fetch it.
|
|
||||||
/// </param>
|
|
||||||
public static PublicUserProfileDto ToPublicProfileDto(
|
|
||||||
this UserAccount user,
|
|
||||||
IFileStorageProvider fileStorageProvider
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
string? avatarKey = user.UserAvatar?.Photo?.Hyperlink;
|
|
||||||
|
|
||||||
return new PublicUserProfileDto(
|
return new PublicUserProfileDto(
|
||||||
UserAccountId : user.UserAccountId,
|
UserAccountId : user.UserAccountId,
|
||||||
Username : user.Username,
|
Username : user.Username,
|
||||||
FirstName : user.FirstName,
|
FirstName : user.FirstName,
|
||||||
LastName : user.LastName,
|
LastName : user.LastName,
|
||||||
Biography : user.UserProfile?.Biography ?? string.Empty,
|
Biography : user.UserProfile?.Biography ?? string.Empty,
|
||||||
AvatarUrl : string.IsNullOrWhiteSpace(avatarKey)
|
|
||||||
? string.Empty
|
|
||||||
: fileStorageProvider.GetPresignedUrl(avatarKey, AvatarUrlLifetime),
|
|
||||||
CreatedAt : user.CreatedAt
|
CreatedAt : user.CreatedAt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,5 @@ public record PublicUserProfileDto(
|
|||||||
string FirstName,
|
string FirstName,
|
||||||
string LastName,
|
string LastName,
|
||||||
string Biography,
|
string Biography,
|
||||||
string AvatarUrl,
|
|
||||||
DateTime CreatedAt
|
DateTime CreatedAt
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Features.ImageUploads.Services;
|
|
||||||
using Features.Users.Dtos;
|
using Features.Users.Dtos;
|
||||||
using Features.Users.Repository;
|
using Features.Users.Repository;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
namespace Features.Users.Queries.GetAllUsers;
|
namespace Features.Users.Queries.GetAllUsers;
|
||||||
|
|
||||||
public class GetAllUsersHandler(
|
public class GetAllUsersHandler(IUserListRepository repository)
|
||||||
IUserListRepository repository,
|
: IRequestHandler<GetAllUsersQuery, IEnumerable<PublicUserProfileDto>>
|
||||||
IFileStorageProvider fileStorageProvider
|
|
||||||
) : IRequestHandler<GetAllUsersQuery, IEnumerable<PublicUserProfileDto>>
|
|
||||||
{
|
{
|
||||||
public async Task<IEnumerable<PublicUserProfileDto>> Handle(
|
public async Task<IEnumerable<PublicUserProfileDto>> Handle(
|
||||||
GetAllUsersQuery request,
|
GetAllUsersQuery request,
|
||||||
@@ -17,6 +14,6 @@ public class GetAllUsersHandler(
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
IEnumerable<UserAccount> users = await repository.GetAllAsync(request.Limit, request.Offset);
|
IEnumerable<UserAccount> users = await repository.GetAllAsync(request.Limit, request.Offset);
|
||||||
return users.Select(user => user.ToPublicProfileDto(fileStorageProvider));
|
return users.Select(user => user.ToPublicProfileDto());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Features.ImageUploads.Services;
|
|
||||||
using Features.Users.Dtos;
|
using Features.Users.Dtos;
|
||||||
using Features.Users.Repository;
|
using Features.Users.Repository;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
namespace Features.Users.Queries.GetPublicUserProfileById;
|
namespace Features.Users.Queries.GetPublicUserProfileById;
|
||||||
|
|
||||||
public class GetPublicUserProfileByIdHandler(
|
public class GetPublicUserProfileByIdHandler(IUserListRepository repository)
|
||||||
IUserListRepository repository,
|
: IRequestHandler<GetPublicUserProfileByIdQuery, PublicUserProfileDto>
|
||||||
IFileStorageProvider fileStorageProvider
|
|
||||||
) : IRequestHandler<GetPublicUserProfileByIdQuery, PublicUserProfileDto>
|
|
||||||
{
|
{
|
||||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||||
public async Task<PublicUserProfileDto> Handle(
|
public async Task<PublicUserProfileDto> Handle(
|
||||||
@@ -21,6 +18,6 @@ public class GetPublicUserProfileByIdHandler(
|
|||||||
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
|
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
|
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
|
||||||
return user.ToPublicProfileDto(fileStorageProvider);
|
return user.ToPublicProfileDto();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,7 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Left-joins <c>Social.UserProfile</c>, so <see cref="UserAccount.UserProfile" /> is
|
/// Left-joins <c>Social.UserProfile</c>, so <see cref="UserAccount.UserProfile" /> is
|
||||||
/// <c>null</c> for an account whose profile has yet to be created, and left-joins the
|
/// <c>null</c> for an account whose profile has yet to be created.
|
||||||
/// account's active <c>Social.UserAvatar</c> (<c>ValidTo IS NULL</c>) with its
|
|
||||||
/// <c>Media.Photo</c>, so <see cref="UserAccount.UserAvatar" /> is <c>null</c> for an account
|
|
||||||
/// with no avatar.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public async Task<UserAccount?> GetByIdAsync(Guid id)
|
public async Task<UserAccount?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
@@ -26,89 +23,51 @@ public class UserListRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
IEnumerable<UserAccount> results = await connection.QueryAsync<
|
IEnumerable<UserAccount> results = await connection.QueryAsync<
|
||||||
UserAccount,
|
UserAccount,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
Photo,
|
|
||||||
UserAvatar,
|
|
||||||
UserAccount
|
UserAccount
|
||||||
>(
|
>(
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT
|
||||||
ua.UserAccountID, ua.Username, ua.FirstName, ua.LastName,
|
ua.UserAccountID,
|
||||||
ua.Email, ua.CreatedAt, ua.UpdatedAt, ua.DateOfBirth,
|
ua.Username,
|
||||||
ua.RowVersion, up.UserProfileID, up.UserAccountID,
|
ua.FirstName, ua.LastName, ua.Email, ua.CreatedAt, ua.UpdatedAt,
|
||||||
up.Biography, up.RowVersion, p.PhotoID, p.UploadedByID,
|
ua.DateOfBirth, ua.RowVersion,
|
||||||
p.Hyperlink, p.UploadedAt, p.RowVersion, av.UserAvatarID,
|
up.UserProfileID, up.UserAccountID, up.Biography, up.RowVersion
|
||||||
av.UserProfileID, av.PhotoID, av.ValidFrom, av.ValidTo,
|
|
||||||
av.RowVersion
|
|
||||||
FROM Auth.UserAccount ua
|
FROM Auth.UserAccount ua
|
||||||
LEFT JOIN Social.UserProfile up
|
LEFT JOIN Social.UserProfile up ON ua.UserAccountID = up.UserAccountID
|
||||||
ON ua.UserAccountID = up.UserAccountID
|
|
||||||
LEFT JOIN Social.UserAvatar av
|
|
||||||
ON up.UserProfileID = av.UserProfileID
|
|
||||||
AND av.ValidTo IS NULL
|
|
||||||
LEFT JOIN Media.Photo p ON av.PhotoID = p.PhotoID
|
|
||||||
WHERE ua.UserAccountID = @UserAccountId
|
WHERE ua.UserAccountID = @UserAccountId
|
||||||
""",
|
""",
|
||||||
MapUserRow,
|
MapUserRow,
|
||||||
new { UserAccountId = id },
|
new { UserAccountId = id },
|
||||||
splitOn: AvatarSplitOn
|
splitOn: "UserProfileID"
|
||||||
);
|
);
|
||||||
|
|
||||||
return results.SingleOrDefault();
|
return results.SingleOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static UserAccount MapUserRow(UserAccount user, UserProfile? profile)
|
||||||
|
{
|
||||||
|
user.UserProfile = profile;
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
|
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
|
||||||
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
|
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
|
||||||
/// Joins the same profile and active-avatar rows as <see cref="GetByIdAsync" />, both of which are
|
/// Does not select <c>Email</c> or <c>DateOfBirth</c>: this listing is consumed only as public
|
||||||
/// part of a public profile. Does not select <c>Email</c> or <c>DateOfBirth</c>: this listing is
|
/// profiles, so those fields are left at their default rather than read from the database.
|
||||||
/// consumed only as public profiles, so those fields are left at their default rather than read
|
|
||||||
/// from the database.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
|
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
|
||||||
{
|
{
|
||||||
await using DbConnection connection = await CreateConnection();
|
await using DbConnection connection = await CreateConnection();
|
||||||
return await connection.QueryAsync<
|
return await connection.QueryAsync<UserAccount>(
|
||||||
UserAccount,
|
|
||||||
UserProfile,
|
|
||||||
Photo,
|
|
||||||
UserAvatar,
|
|
||||||
UserAccount
|
|
||||||
>(
|
|
||||||
"""
|
"""
|
||||||
SELECT
|
SELECT UserAccountID, Username, FirstName, LastName, CreatedAt, UpdatedAt, RowVersion
|
||||||
ua.UserAccountID, ua.Username, ua.FirstName, ua.LastName,
|
FROM Auth.UserAccount
|
||||||
ua.CreatedAt, ua.UpdatedAt, ua.RowVersion,
|
ORDER BY CreatedAt DESC
|
||||||
up.UserProfileID, up.UserAccountID, up.Biography, up.RowVersion,
|
|
||||||
p.PhotoID, p.UploadedByID, p.Hyperlink, p.UploadedAt, p.RowVersion,
|
|
||||||
av.UserAvatarID, av.UserProfileID, av.PhotoID, av.ValidFrom, av.ValidTo,
|
|
||||||
av.RowVersion
|
|
||||||
FROM Auth.UserAccount ua
|
|
||||||
LEFT JOIN Social.UserProfile up ON ua.UserAccountID = up.UserAccountID
|
|
||||||
LEFT JOIN Social.UserAvatar av
|
|
||||||
ON up.UserProfileID = av.UserProfileID AND av.ValidTo IS NULL
|
|
||||||
LEFT JOIN Media.Photo p ON av.PhotoID = p.PhotoID
|
|
||||||
ORDER BY ua.CreatedAt DESC
|
|
||||||
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
|
||||||
""",
|
""",
|
||||||
MapUserRow,
|
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }
|
||||||
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue },
|
|
||||||
splitOn: AvatarSplitOn
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private const string AvatarSplitOn = "UserProfileID,PhotoID,UserAvatarID";
|
|
||||||
|
|
||||||
private static UserAccount MapUserRow(
|
|
||||||
UserAccount user,
|
|
||||||
UserProfile? profile,
|
|
||||||
Photo? photo,
|
|
||||||
UserAvatar? avatar
|
|
||||||
)
|
|
||||||
{
|
|
||||||
user.UserProfile = profile;
|
|
||||||
avatar?.Photo = photo;
|
|
||||||
user.UserAvatar = avatar;
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ services:
|
|||||||
DB_USER: "${DB_USER}"
|
DB_USER: "${DB_USER}"
|
||||||
DB_PASSWORD: "${DB_PASSWORD}"
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
|
||||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ services:
|
|||||||
DB_USER: "${DB_USER}"
|
DB_USER: "${DB_USER}"
|
||||||
DB_PASSWORD: "${DB_PASSWORD}"
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
|
||||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||||
@@ -111,7 +110,6 @@ services:
|
|||||||
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||||
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
|
||||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ services:
|
|||||||
DB_USER: "${DB_USER}"
|
DB_USER: "${DB_USER}"
|
||||||
DB_PASSWORD: "${DB_PASSWORD}"
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
|
||||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
# docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml run --rm --build database.seed
|
|
||||||
name: biergarten-prod-seed
|
|
||||||
|
|
||||||
services:
|
|
||||||
database.seed:
|
|
||||||
env_file: ".env.prod"
|
|
||||||
image: database.seed
|
|
||||||
container_name: prod-env-database-seed
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: Database/Database.Seed/Dockerfile
|
|
||||||
args:
|
|
||||||
BUILD_CONFIGURATION: Release
|
|
||||||
APP_UID: 1000
|
|
||||||
environment:
|
|
||||||
DOTNET_RUNNING_IN_CONTAINER: "true"
|
|
||||||
DB_SERVER: "${DB_SERVER}"
|
|
||||||
DB_NAME: "${DB_NAME}"
|
|
||||||
DB_USER: "${DB_USER}"
|
|
||||||
DB_PASSWORD: "${DB_PASSWORD}"
|
|
||||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
|
||||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
|
||||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
|
||||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
|
||||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
|
||||||
restart: "no"
|
|
||||||
networks:
|
|
||||||
- prodnet
|
|
||||||
|
|
||||||
networks:
|
|
||||||
prodnet:
|
|
||||||
name: biergarten-prodnet
|
|
||||||
external: true
|
|
||||||
@@ -20,8 +20,58 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 12
|
retries: 12
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
restart: unless-stopped
|
networks:
|
||||||
stop_grace_period: 60s
|
- prodnet
|
||||||
|
|
||||||
|
database.migrations:
|
||||||
|
env_file: ".env.prod"
|
||||||
|
image: database.migrations
|
||||||
|
container_name: prod-env-database-migrations
|
||||||
|
depends_on:
|
||||||
|
sqlserver:
|
||||||
|
condition: service_healthy
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Database/Database.Migrations/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- prodnet
|
||||||
|
|
||||||
|
database.seed:
|
||||||
|
env_file: ".env.prod"
|
||||||
|
image: database.seed
|
||||||
|
container_name: prod-env-database-seed
|
||||||
|
depends_on:
|
||||||
|
database.migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
seaweedfs:
|
||||||
|
condition: service_started
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Database/Database.Seed/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||||
|
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||||
|
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||||
|
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||||
|
restart: "no"
|
||||||
networks:
|
networks:
|
||||||
- prodnet
|
- prodnet
|
||||||
|
|
||||||
@@ -30,14 +80,18 @@ services:
|
|||||||
image: api.core
|
image: api.core
|
||||||
container_name: prod-env-api-core
|
container_name: prod-env-api-core
|
||||||
depends_on:
|
depends_on:
|
||||||
|
database.seed:
|
||||||
|
condition: service_completed_successfully
|
||||||
seaweedfs:
|
seaweedfs:
|
||||||
condition: service_healthy
|
condition: service_started
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: API/API.Core/Dockerfile
|
dockerfile: API/API.Core/Dockerfile
|
||||||
args:
|
args:
|
||||||
BUILD_CONFIGURATION: Release
|
BUILD_CONFIGURATION: Release
|
||||||
APP_UID: 1000
|
APP_UID: 1000
|
||||||
|
# No published ports: only the frontend service is reachable from outside
|
||||||
|
# the Docker network in production. api.core stays internal to prodnet.
|
||||||
environment:
|
environment:
|
||||||
ASPNETCORE_ENVIRONMENT: "Production"
|
ASPNETCORE_ENVIRONMENT: "Production"
|
||||||
ASPNETCORE_URLS: "http://0.0.0.0:8080"
|
ASPNETCORE_URLS: "http://0.0.0.0:8080"
|
||||||
@@ -51,42 +105,27 @@ services:
|
|||||||
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||||
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
|
||||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
[
|
|
||||||
"CMD",
|
|
||||||
"bash",
|
|
||||||
"-c",
|
|
||||||
'exec 3<>/dev/tcp/127.0.0.1/8080; printf "GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; head -1 <&3 | grep -q " 200 "',
|
|
||||||
]
|
|
||||||
interval: 15s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 30s
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- prodnet
|
- prodnet
|
||||||
|
volumes:
|
||||||
|
- nuget-cache-prod:/root/.nuget/packages
|
||||||
|
|
||||||
seaweedfs:
|
seaweedfs:
|
||||||
env_file: ".env.prod"
|
env_file: ".env.prod"
|
||||||
image: chrislusf/seaweedfs
|
image: chrislusf/seaweedfs
|
||||||
container_name: prod-env-seaweedfs
|
container_name: prod-env-seaweedfs
|
||||||
|
# No published ports: the S3 API, filer, master and admin UIs are reachable
|
||||||
|
# only from other containers on prodnet.
|
||||||
environment:
|
environment:
|
||||||
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
AWS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||||
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
AWS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||||
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
|
S3_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||||
volumes:
|
volumes:
|
||||||
- seaweedfsdata-prod:/data
|
- seaweedfsdata-prod:/data
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:8333/status"]
|
|
||||||
interval: 15s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 20s
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- prodnet
|
- prodnet
|
||||||
@@ -97,7 +136,7 @@ services:
|
|||||||
container_name: prod-env-frontend
|
container_name: prod-env-frontend
|
||||||
depends_on:
|
depends_on:
|
||||||
api.core:
|
api.core:
|
||||||
condition: service_healthy
|
condition: service_started
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -108,23 +147,21 @@ services:
|
|||||||
PORT: "5656"
|
PORT: "5656"
|
||||||
API_BASE_URL: "http://api.core:8080"
|
API_BASE_URL: "http://api.core:8080"
|
||||||
SESSION_SECRET: "${SESSION_SECRET}"
|
SESSION_SECRET: "${SESSION_SECRET}"
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--quiet", "--spider", "--tries=1", "http://127.0.0.1:5656/healthz"]
|
|
||||||
interval: 15s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 15s
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- prodnet
|
- prodnet
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
# Declared external so `docker compose down -v` leaves the data alone: Compose
|
||||||
|
# removes only the volumes it owns. Create them once with `docker volume
|
||||||
|
# create` before the first `up`.
|
||||||
biergartendata-prod:
|
biergartendata-prod:
|
||||||
external: true
|
external: true
|
||||||
seaweedfsdata-prod:
|
seaweedfsdata-prod:
|
||||||
external: true
|
external: true
|
||||||
|
nuget-cache-prod:
|
||||||
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
prodnet:
|
prodnet:
|
||||||
name: biergarten-prodnet
|
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ services:
|
|||||||
DB_USER: "${DB_USER}"
|
DB_USER: "${DB_USER}"
|
||||||
DB_PASSWORD: "${DB_PASSWORD}"
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
|
||||||
SEAWEEDFS_PUBLIC_URL: "${SEAWEEDFS_PUBLIC_URL}"
|
|
||||||
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
|
||||||
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
|
||||||
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"
|
||||||
|
|||||||
@@ -34,38 +34,38 @@ h6,
|
|||||||
--color-base-100: oklch(96% 0.012 82); /* warm parchment */
|
--color-base-100: oklch(96% 0.012 82); /* warm parchment */
|
||||||
--color-base-200: oklch(92% 0.018 80); /* brushed paper */
|
--color-base-200: oklch(92% 0.018 80); /* brushed paper */
|
||||||
--color-base-300: oklch(87% 0.025 78); /* tinted linen */
|
--color-base-300: oklch(87% 0.025 78); /* tinted linen */
|
||||||
--color-base-content: oklch(28% 0.025 58); /* dark brown ink */
|
--color-base-content: oklch(28% 0.025 58); /* dark brown ink — 15.6:1 on base-100 */
|
||||||
|
|
||||||
--color-primary: oklch(47.5% 0.085 62); /* mellow amber */
|
--color-primary: oklch(65% 0.085 62); /* mellow amber */
|
||||||
--color-primary-content: oklch(97% 0.02 62); /* warm near-white */
|
--color-primary-content: oklch(97% 0.02 62); /* warm near-white — 7.2:1 on primary */
|
||||||
|
|
||||||
--color-secondary: oklch(42% 0.05 42); /* softened mahogany */
|
--color-secondary: oklch(42% 0.05 42); /* softened mahogany */
|
||||||
--color-secondary-content: oklch(96% 0.01 76); /* off-white */
|
--color-secondary-content: oklch(96% 0.01 76); /* off-white — 14.2:1 on secondary */
|
||||||
|
|
||||||
--color-accent: oklch(93% 0.015 90); /* frothy cream */
|
--color-accent: oklch(93% 0.015 90); /* frothy cream */
|
||||||
--color-accent-content: oklch(28% 0.025 58); /* dark brown */
|
--color-accent-content: oklch(28% 0.025 58); /* dark brown — 12.8:1 on accent */
|
||||||
|
|
||||||
--color-neutral: oklch(28% 0.02 46); /* warm roast dark */
|
--color-neutral: oklch(28% 0.02 46); /* warm roast dark */
|
||||||
--color-neutral-content: oklch(92% 0.012 80); /* pale parchment */
|
--color-neutral-content: oklch(92% 0.012 80); /* pale parchment — 12.0:1 on neutral */
|
||||||
|
|
||||||
--color-info: oklch(46% 0.065 145); /* muted hop green */
|
--color-info: oklch(46% 0.065 145); /* muted hop green */
|
||||||
--color-info-content: oklch(97% 0.008 145); /* near-white */
|
--color-info-content: oklch(97% 0.008 145); /* near-white — 14.2:1 on info */
|
||||||
|
|
||||||
--color-success: oklch(46.5% 0.06 122); /* soft barley gold */
|
--color-success: oklch(70% 0.06 122); /* soft barley gold */
|
||||||
--color-success-content: oklch(97% 0.02 122); /* warm near-white */
|
--color-success-content: oklch(97% 0.02 122); /* warm near-white — 5.7:1 on success */
|
||||||
|
|
||||||
--color-warning: oklch(47.5% 0.09 56); /* toned amber */
|
--color-warning: oklch(72% 0.09 56); /* toned amber */
|
||||||
--color-warning-content: oklch(97% 0.02 56); /* warm near-white */
|
--color-warning-content: oklch(97% 0.02 56); /* warm near-white — 4.7:1 on warning */
|
||||||
|
|
||||||
--color-error: oklch(48% 0.09 22); /* restrained cherry */
|
--color-error: oklch(54% 0.09 22); /* restrained cherry */
|
||||||
--color-error-content: oklch(97% 0.006 15); /* near-white */
|
--color-error-content: oklch(97% 0.006 15); /* near-white — 11.1:1 on error */
|
||||||
|
|
||||||
--color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */
|
--color-surface: oklch(88% 0.02 82); /* mid parchment, elevated cards */
|
||||||
--color-surface-content: oklch(28% 0.025 58); /* dark brown */
|
--color-surface-content: oklch(28% 0.025 58); /* dark brown — 9.2:1 on surface */
|
||||||
|
|
||||||
--color-muted: oklch(42% 0.055 62); /* amber-brown */
|
--color-muted: oklch(42% 0.055 62); /* amber-brown — 14.2:1 on base-100, 8.3:1 on surface */
|
||||||
--color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */
|
--color-highlight: oklch(78% 0.055 65); /* warm amber, hover and active states */
|
||||||
--color-highlight-content: oklch(22% 0.025 55); /* dark brown */
|
--color-highlight-content: oklch(22% 0.025 55); /* dark brown — 4.9:1 on highlight */
|
||||||
|
|
||||||
--radius-selector: 0.375rem;
|
--radius-selector: 0.375rem;
|
||||||
--radius-field: 0.5rem;
|
--radius-field: 0.5rem;
|
||||||
@@ -91,38 +91,38 @@ h6,
|
|||||||
--color-base-100: oklch(14% 0.006 45); /* charred barrel black */
|
--color-base-100: oklch(14% 0.006 45); /* charred barrel black */
|
||||||
--color-base-200: oklch(18% 0.008 43); /* roasted malt dark */
|
--color-base-200: oklch(18% 0.008 43); /* roasted malt dark */
|
||||||
--color-base-300: oklch(23% 0.01 42); /* deep brown */
|
--color-base-300: oklch(23% 0.01 42); /* deep brown */
|
||||||
--color-base-content: oklch(88% 0.008 75); /* warm off-white */
|
--color-base-content: oklch(88% 0.008 75); /* warm off-white — 9.4:1 on base-100 */
|
||||||
|
|
||||||
--color-primary: oklch(68% 0.055 60); /* golden amber */
|
--color-primary: oklch(68% 0.055 60); /* golden amber */
|
||||||
--color-primary-content: oklch(30.5% 0.012 50); /* warm off-white */
|
--color-primary-content: oklch(92% 0.012 50); /* warm off-white — 4.6:1 on primary */
|
||||||
|
|
||||||
--color-secondary: oklch(62.5% 0.035 40); /* deep mahogany ale */
|
--color-secondary: oklch(48% 0.035 40); /* deep mahogany ale */
|
||||||
--color-secondary-content: oklch(23.5% 0.005 75); /* near-white */
|
--color-secondary-content: oklch(97% 0.005 75); /* near-white — 13.9:1 on secondary */
|
||||||
|
|
||||||
--color-accent: oklch(82% 0.01 88); /* frothy cream head */
|
--color-accent: oklch(82% 0.01 88); /* frothy cream head */
|
||||||
--color-accent-content: oklch(18% 0.012 55); /* near-black */
|
--color-accent-content: oklch(18% 0.012 55); /* near-black — 6.2:1 on accent */
|
||||||
|
|
||||||
--color-neutral: oklch(20% 0.008 45); /* near-black with warmth */
|
--color-neutral: oklch(20% 0.008 45); /* near-black with warmth */
|
||||||
--color-neutral-content: oklch(88% 0.007 78); /* warm off-white */
|
--color-neutral-content: oklch(88% 0.007 78); /* warm off-white — 9.3:1 on neutral */
|
||||||
|
|
||||||
--color-info: oklch(61.5% 0.04 145); /* cool hop green */
|
--color-info: oklch(60% 0.04 145); /* cool hop green */
|
||||||
--color-info-content: oklch(23% 0.006 145); /* pale green-white */
|
--color-info-content: oklch(86% 0.006 145); /* pale green-white — 4.6:1 on info */
|
||||||
|
|
||||||
--color-success: oklch(66% 0.038 120); /* fresh barley */
|
--color-success: oklch(66% 0.038 120); /* fresh barley */
|
||||||
--color-success-content: oklch(28.5% 0.012 120); /* pale barley-white */
|
--color-success-content: oklch(90% 0.012 120); /* pale barley-white — 4.6:1 on success */
|
||||||
|
|
||||||
--color-warning: oklch(70% 0.055 55); /* amber harvest */
|
--color-warning: oklch(70% 0.055 55); /* amber harvest */
|
||||||
--color-warning-content: oklch(32.5% 0.012 55); /* warm near-white */
|
--color-warning-content: oklch(94% 0.012 55); /* warm near-white — 4.7:1 on warning */
|
||||||
|
|
||||||
--color-error: oklch(62.5% 0.06 20); /* deep cherry kriek */
|
--color-error: oklch(50% 0.06 20); /* deep cherry kriek */
|
||||||
--color-error-content: oklch(23% 0.004 15); /* near-white */
|
--color-error-content: oklch(97% 0.004 15); /* near-white — 13.1:1 on error */
|
||||||
|
|
||||||
--color-surface: oklch(26% 0.012 45); /* elevated dark panel */
|
--color-surface: oklch(26% 0.012 45); /* elevated dark panel */
|
||||||
--color-surface-content: oklch(88% 0.008 75); /* warm off-white */
|
--color-surface-content: oklch(88% 0.008 75); /* warm off-white — 9.2:1 on surface */
|
||||||
|
|
||||||
--color-muted: oklch(78% 0.018 72); /* warm grey */
|
--color-muted: oklch(78% 0.018 72); /* warm grey — 4.7:1 on base-100, 4.6:1 on surface */
|
||||||
--color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */
|
--color-highlight: oklch(32% 0.025 48); /* warm dark brown, hover and active states */
|
||||||
--color-highlight-content: oklch(88% 0.008 75); /* warm off-white */
|
--color-highlight-content: oklch(88% 0.008 75); /* warm off-white — 9.0:1 on highlight */
|
||||||
|
|
||||||
--radius-selector: 0.375rem;
|
--radius-selector: 0.375rem;
|
||||||
--radius-field: 0.5rem;
|
--radius-field: 0.5rem;
|
||||||
@@ -148,38 +148,40 @@ h6,
|
|||||||
--color-base-100: oklch(13% 0.01 295); /* blackberry-stained near-black */
|
--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-200: oklch(17% 0.013 292); /* deep purple-black */
|
||||||
--color-base-300: oklch(22% 0.016 290); /* dark grape */
|
--color-base-300: oklch(22% 0.016 290); /* dark grape */
|
||||||
--color-base-content: oklch(90% 0.014 300); /* pale lavender-white */
|
--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: oklch(72% 0.075 295); /* cassis berry purple */
|
||||||
--color-primary-content: oklch(34% 0.01 295); /* pale lavender */
|
--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: oklch(68% 0.06 10); /* sour cherry rose */
|
||||||
--color-secondary-content: oklch(30% 0.006 10); /* warm near-white */
|
--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: oklch(75% 0.045 130); /* tart lime zest */
|
||||||
--color-accent-content: oklch(37.5% 0.01 130); /* near-white */
|
--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: oklch(18% 0.016 290); /* deep blackened grape */
|
||||||
--color-neutral-content: oklch(88% 0.01 295); /* pale lavender */
|
--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: oklch(62% 0.04 250); /* muted indigo */
|
||||||
--color-info-content: oklch(23% 0.008 250); /* pale indigo-white */
|
--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: oklch(65% 0.04 145); /* elderberry green */
|
||||||
--color-success-content: oklch(27.5% 0.008 145); /* pale green-white */
|
--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: oklch(70% 0.05 65); /* sour apricot */
|
||||||
--color-warning-content: oklch(32.5% 0.03 65); /* near-white */
|
--color-warning-content: oklch(97% 0.03 65); /* near-white — 5.6:1 on warning */
|
||||||
|
|
||||||
--color-error: oklch(62% 0.055 22); /* kriek red */
|
--color-error: oklch(50% 0.055 22); /* kriek red */
|
||||||
--color-error-content: oklch(22% 0.006 22); /* near-white */
|
--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: oklch(27% 0.022 292); /* lifted purple-black panel */
|
||||||
--color-surface-content: oklch(90% 0.014 300); /* pale lavender-white */
|
--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 */
|
--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: oklch(35% 0.04 295); /* cassis-tinted hover and active state */
|
||||||
--color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white */
|
--color-highlight-content: oklch(90% 0.014 300); /* pale lavender-white — 10.1:1 on highlight */
|
||||||
|
|
||||||
--radius-selector: 0.5rem;
|
--radius-selector: 0.5rem;
|
||||||
--radius-field: 0.5rem;
|
--radius-field: 0.5rem;
|
||||||
@@ -205,38 +207,38 @@ h6,
|
|||||||
--color-base-100: oklch(99% 0.007 112); /* near-white with faint barley-green tint */
|
--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-200: oklch(96% 0.012 114); /* pale barley wash */
|
||||||
--color-base-300: oklch(92% 0.019 116); /* light straw */
|
--color-base-300: oklch(92% 0.019 116); /* light straw */
|
||||||
--color-base-content: oklch(20% 0.022 122); /* deep green-black */
|
--color-base-content: oklch(20% 0.022 122); /* deep green-black — 19.5:1 on base-100 */
|
||||||
|
|
||||||
--color-primary: oklch(50% 0.085 118); /* fresh-cut barley green */
|
--color-primary: oklch(52% 0.085 118); /* fresh-cut barley green */
|
||||||
--color-primary-content: oklch(97% 0.005 118); /* near-white */
|
--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: oklch(44% 0.055 128); /* muted sage stem */
|
||||||
--color-secondary-content: oklch(97% 0.005 128); /* near-white */
|
--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: oklch(93% 0.03 148); /* pale morning dew */
|
||||||
--color-accent-content: oklch(22% 0.022 148); /* deep green */
|
--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: oklch(76% 0.028 118); /* dried straw, surface differentiation */
|
||||||
--color-neutral-content: oklch(38.4% 0.005 118); /* near-white */
|
--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: oklch(38% 0.065 232); /* clear summer sky */
|
||||||
--color-info-content: oklch(98% 0.005 232); /* near-white */
|
--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: oklch(38% 0.085 145); /* young shoot green */
|
||||||
--color-success-content: oklch(98% 0.005 145); /* near-white */
|
--color-success-content: oklch(98% 0.005 145); /* near-white — 16.8:1 on success */
|
||||||
|
|
||||||
--color-warning: oklch(51% 0.1 76); /* ripening grain amber */
|
--color-warning: oklch(68% 0.1 76); /* ripening grain amber */
|
||||||
--color-warning-content: oklch(92.5% 0.005 72); /* warm near-white */
|
--color-warning-content: oklch(92.5% 0.005 72); /* warm near-white — 4.5:1 on warning */
|
||||||
|
|
||||||
--color-error: oklch(51.5% 0.1 18); /* dusty rose red */
|
--color-error: oklch(52% 0.1 18); /* dusty rose red */
|
||||||
--color-error-content: oklch(98% 0.005 15); /* near-white */
|
--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: oklch(94% 0.012 112); /* soft barley-wash panel */
|
||||||
--color-surface-content: oklch(20% 0.022 122); /* deep green-black */
|
--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 */
|
--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: oklch(85% 0.04 118); /* green-tinted hover and active state */
|
||||||
--color-highlight-content: oklch(20% 0.022 122); /* deep green-black */
|
--color-highlight-content: oklch(20% 0.022 122); /* deep green-black — 7.8:1 on highlight */
|
||||||
|
|
||||||
--radius-selector: 2rem;
|
--radius-selector: 2rem;
|
||||||
--radius-field: 2rem;
|
--radius-field: 2rem;
|
||||||
@@ -247,33 +249,3 @@ h6,
|
|||||||
--depth: 0;
|
--depth: 0;
|
||||||
--noise: 0;
|
--noise: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* WCAG 1.4.11: form-field borders at >= 3:1 against every base layer */
|
|
||||||
:is(.input, .select, .textarea):not(
|
|
||||||
.input-neutral,
|
|
||||||
.input-primary,
|
|
||||||
.input-secondary,
|
|
||||||
.input-accent,
|
|
||||||
.input-info,
|
|
||||||
.input-success,
|
|
||||||
.input-warning,
|
|
||||||
.input-error,
|
|
||||||
.select-neutral,
|
|
||||||
.select-primary,
|
|
||||||
.select-secondary,
|
|
||||||
.select-accent,
|
|
||||||
.select-info,
|
|
||||||
.select-success,
|
|
||||||
.select-warning,
|
|
||||||
.select-error,
|
|
||||||
.textarea-neutral,
|
|
||||||
.textarea-primary,
|
|
||||||
.textarea-secondary,
|
|
||||||
.textarea-accent,
|
|
||||||
.textarea-info,
|
|
||||||
.textarea-success,
|
|
||||||
.textarea-warning,
|
|
||||||
.textarea-error
|
|
||||||
) {
|
|
||||||
--input-color: color-mix(in oklab, var(--color-base-content) 56%, #0000);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,26 +21,19 @@ const FormField = ({
|
|||||||
}: FormFieldProps) => {
|
}: FormFieldProps) => {
|
||||||
return (
|
return (
|
||||||
<Field className={className ?? 'space-y-1'}>
|
<Field className={className ?? 'space-y-1'}>
|
||||||
<Label
|
<Label htmlFor={inputProps.id} className={labelClassName ?? 'label font-medium'}>
|
||||||
htmlFor={inputProps.id}
|
|
||||||
className={labelClassName ?? 'label text-sm font-semibold text-base-content'}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
className={
|
className={inputClassName ?? `input w-full ${error ? 'input-error' : ''}`}
|
||||||
inputClassName ?? `input h-11 w-full text-base ${error ? 'input-error' : ''}`
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Description className={hintClassName ?? 'text-xs text-error'}>{error}</Description>
|
<Description className={hintClassName ?? 'label text-error'}>{error}</Description>
|
||||||
) : hint ? (
|
) : hint ? (
|
||||||
<Description className={hintClassName ?? 'text-xs text-[var(--color-muted)]'}>
|
<Description className={hintClassName ?? 'label'}>{hint}</Description>
|
||||||
{hint}
|
|
||||||
</Description>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react';
|
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react';
|
||||||
import { Link, useLocation } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import NavLinks from './NavLinks';
|
import NavLinks from './NavLinks';
|
||||||
import UserMenu from './UserMenu';
|
import UserMenu from './UserMenu';
|
||||||
|
|
||||||
@@ -12,12 +12,7 @@ interface NavbarProps {
|
|||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Navbar = ({ auth }: NavbarProps) => {
|
const Navbar = ({ auth }: NavbarProps) => (
|
||||||
const { pathname } = useLocation();
|
|
||||||
const onAuthRoute = pathname === '/login' || pathname === '/register';
|
|
||||||
const showAuthActions = !auth && !onAuthRoute;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Disclosure
|
<Disclosure
|
||||||
as="nav"
|
as="nav"
|
||||||
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
|
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
|
||||||
@@ -54,15 +49,15 @@ const Navbar = ({ auth }: NavbarProps) => {
|
|||||||
</svg>
|
</svg>
|
||||||
</DisclosureButton>
|
</DisclosureButton>
|
||||||
|
|
||||||
<Link to="/" className="lg:text-xl text-lg font-bold">
|
<Link to="/" className="text-xl font-bold">
|
||||||
The Biergarten App
|
🍺 The Biergarten App
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NavLinks variant="desktop" showRegister={showAuthActions} />
|
<NavLinks variant="desktop" showRegister={!auth} />
|
||||||
|
|
||||||
<div className="navbar-end gap-2">
|
<div className="navbar-end gap-2">
|
||||||
{showAuthActions && (
|
{!auth && (
|
||||||
<Link
|
<Link
|
||||||
to="/register"
|
to="/register"
|
||||||
className="btn btn-ghost btn-sm hidden sm:inline-flex"
|
className="btn btn-ghost btn-sm hidden sm:inline-flex"
|
||||||
@@ -83,22 +78,19 @@ const Navbar = ({ auth }: NavbarProps) => {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
!onAuthRoute && (
|
|
||||||
<Link to="/login" className="btn btn-primary btn-sm">
|
<Link to="/login" className="btn btn-primary btn-sm">
|
||||||
Login
|
Login
|
||||||
</Link>
|
</Link>
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
|
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
|
||||||
<NavLinks variant="mobile" showRegister={showAuthActions} />
|
<NavLinks variant="mobile" showRegister={!auth} />
|
||||||
</DisclosurePanel>
|
</DisclosurePanel>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
export default Navbar;
|
export default Navbar;
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const AuthFlowDemoCard = () => (
|
|||||||
<li className="list-row">
|
<li className="list-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Login</p>
|
<p className="font-semibold">Login</p>
|
||||||
<p className="text-sm text-base-content/70">
|
<p className="text-sm text-base-content/60">
|
||||||
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
|
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
|
||||||
username & password
|
username & password
|
||||||
</p>
|
</p>
|
||||||
@@ -20,7 +20,7 @@ const AuthFlowDemoCard = () => (
|
|||||||
<li className="list-row">
|
<li className="list-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Register</p>
|
<p className="font-semibold">Register</p>
|
||||||
<p className="text-sm text-base-content/70">
|
<p className="text-sm text-base-content/60">
|
||||||
POST to <code className="kbd kbd-sm">/api/auth/register</code> with full
|
POST to <code className="kbd kbd-sm">/api/auth/register</code> with full
|
||||||
user details
|
user details
|
||||||
</p>
|
</p>
|
||||||
@@ -29,7 +29,7 @@ const AuthFlowDemoCard = () => (
|
|||||||
<li className="list-row">
|
<li className="list-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Session</p>
|
<p className="font-semibold">Session</p>
|
||||||
<p className="text-sm text-base-content/70">
|
<p className="text-sm text-base-content/60">
|
||||||
JWT access & refresh tokens stored in an HTTP-only cookie
|
JWT access & refresh tokens stored in an HTTP-only cookie
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +37,7 @@ const AuthFlowDemoCard = () => (
|
|||||||
<li className="list-row">
|
<li className="list-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Protected Routes</p>
|
<p className="font-semibold">Protected Routes</p>
|
||||||
<p className="text-sm text-base-content/70">
|
<p className="text-sm text-base-content/60">
|
||||||
This dashboard requires authentication via{' '}
|
This dashboard requires authentication via{' '}
|
||||||
<code className="kbd kbd-sm">requireAuth()</code>
|
<code className="kbd kbd-sm">requireAuth()</code>
|
||||||
</p>
|
</p>
|
||||||
@@ -46,7 +46,7 @@ const AuthFlowDemoCard = () => (
|
|||||||
<li className="list-row">
|
<li className="list-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Token Refresh</p>
|
<p className="font-semibold">Token Refresh</p>
|
||||||
<p className="text-sm text-base-content/70">
|
<p className="text-sm text-base-content/60">
|
||||||
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with the
|
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with the
|
||||||
refresh token in an <code className="kbd kbd-sm">X-Refresh-Token</code>{' '}
|
refresh token in an <code className="kbd kbd-sm">X-Refresh-Token</code>{' '}
|
||||||
header
|
header
|
||||||
@@ -56,7 +56,7 @@ const AuthFlowDemoCard = () => (
|
|||||||
<li className="list-row">
|
<li className="list-row">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Account Management</p>
|
<p className="font-semibold">Account Management</p>
|
||||||
<p className="text-sm text-base-content/70">
|
<p className="text-sm text-base-content/60">
|
||||||
PATCH <code className="kbd kbd-sm">/api/auth/username</code>,{' '}
|
PATCH <code className="kbd kbd-sm">/api/auth/username</code>,{' '}
|
||||||
<code className="kbd kbd-sm">/email</code>,{' '}
|
<code className="kbd kbd-sm">/email</code>,{' '}
|
||||||
<code className="kbd kbd-sm">/password</code>,{' '}
|
<code className="kbd kbd-sm">/password</code>,{' '}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const SectionCard = ({ icon, title, description, open, onToggle, children }: Sec
|
|||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="flex w-full items-center justify-between gap-5">
|
<div className="flex w-full items-center justify-between gap-5">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<span className="text-base-content/70 mt-1">{icon}</span>
|
<span className="text-base-content/60 mt-1">{icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="card-title text-lg">{title}</h2>
|
<h2 className="card-title text-lg">{title}</h2>
|
||||||
<p className="text-sm text-base-content/70">{description}</p>
|
<p className="text-sm text-base-content/70">{description}</p>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const SessionInfoCard = ({ username, userAccountId }: SessionInfoCardProps) => (
|
|||||||
</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/70 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">
|
||||||
|
|||||||
@@ -208,13 +208,11 @@ const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="mx-auto max-w-7xl px-5 pt-10 pb-10 space-y-6">
|
<div className="mx-auto max-w-2xl px-6 py-10 space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Settings className="size-7" aria-hidden="true" />
|
<Settings className="size-7" aria-hidden="true" />
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-serif text-5xl font-bold leading-tight">
|
<h1 className="text-3xl font-bold">Account Settings</h1>
|
||||||
Account Settings
|
|
||||||
</h1>
|
|
||||||
<p className="text-base-content/70">
|
<p className="text-base-content/70">
|
||||||
Manage the account details for{' '}
|
Manage the account details for{' '}
|
||||||
<span className="font-mono">{username}</span>
|
<span className="font-mono">{username}</span>
|
||||||
@@ -259,7 +257,7 @@ const AccountPage = ({ loaderData, actionData }: Route.ComponentProps) => {
|
|||||||
|
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard"
|
to="/dashboard"
|
||||||
className="link link-hover text-sm text-base-content/70 inline-block"
|
className="link link-hover text-sm text-base-content/60 inline-block"
|
||||||
>
|
>
|
||||||
← Back to dashboard
|
← Back to dashboard
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const Dashboard = ({ loaderData }: Route.ComponentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="mx-auto max-w-7xl px-5 pt-10 pb-10 space-y-6">
|
<div className="mx-auto max-w-4xl px-6 py-10 space-y-6">
|
||||||
<SessionInfoCard username={username} userAccountId={userAccountId} />
|
<SessionInfoCard username={username} userAccountId={userAccountId} />
|
||||||
<AuthFlowDemoCard />
|
<AuthFlowDemoCard />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
interface AuthCardHeadingProps {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthCardHeading = ({ title, subtitle }: AuthCardHeadingProps) => (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<h1 className="text-[1.875rem]">{title}</h1>
|
|
||||||
<p className="text-[var(--color-muted)]">{subtitle}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default AuthCardHeading;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
interface AuthErrorAlertProps {
|
|
||||||
message: string;
|
|
||||||
/** Per-field messages to list beneath the summary. */
|
|
||||||
details?: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthErrorAlert = ({ message, details }: AuthErrorAlertProps) => (
|
|
||||||
<div role="alert" className="alert alert-error alert-soft flex-col items-start">
|
|
||||||
<span>{message}</span>
|
|
||||||
|
|
||||||
{details?.length ? (
|
|
||||||
<ul className="list-inside list-disc text-sm">
|
|
||||||
{details.map((detail) => (
|
|
||||||
<li key={detail}>{detail}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default AuthErrorAlert;
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { expect, fireEvent, userEvent, within } from 'storybook/test';
|
|
||||||
import AuthCardHeading from './AuthCardHeading';
|
|
||||||
import AuthErrorAlert from './AuthErrorAlert';
|
|
||||||
import AuthSplitLayout from './AuthSplitLayout';
|
|
||||||
import AuthSwitchPrompt from './AuthSwitchPrompt';
|
|
||||||
import LoginForm from './LoginForm';
|
|
||||||
import RegisterForm from './RegisterForm';
|
|
||||||
import RegisterStepProgress from './RegisterStepProgress';
|
|
||||||
import { useRegisterWizard } from '../hooks/useRegisterWizard';
|
|
||||||
import { loginSchema, type LoginSchema } from '../schemas';
|
|
||||||
|
|
||||||
const noop = () => undefined;
|
|
||||||
|
|
||||||
const authSplitLayoutDescription = `The shared split-screen shell behind /login and /register: a full-bleed bar photo carrying the hero headline, and a solid card holding the page's own heading, form, and switch prompt. The grid splits 6fr/5fr from 820px up and stacks hero over card below it. Both stories compose the real forms, so they show the production layout at any viewport.`;
|
|
||||||
|
|
||||||
const LoginPanel = ({ error }: { error?: string }) => {
|
|
||||||
const form = useForm<LoginSchema>({ resolver: zodResolver(loginSchema) });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<AuthCardHeading title="Sign in" subtitle="Sign in to your Biergarten account" />
|
|
||||||
|
|
||||||
{error ? <AuthErrorAlert message={error} /> : null}
|
|
||||||
|
|
||||||
<LoginForm
|
|
||||||
onSubmit={form.handleSubmit(noop)}
|
|
||||||
formState={form.formState}
|
|
||||||
register={form.register}
|
|
||||||
submitting={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AuthSwitchPrompt lead="New to Biergarten?" to="/register" label="Create an account" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const RegisterPanel = () => {
|
|
||||||
const wizard = useRegisterWizard(noop);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<RegisterStepProgress
|
|
||||||
step={wizard.step}
|
|
||||||
totalSteps={wizard.totalSteps}
|
|
||||||
name={wizard.name}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AuthCardHeading title={wizard.title} subtitle={wizard.subtitle} />
|
|
||||||
|
|
||||||
<RegisterForm wizard={wizard} submitting={false} />
|
|
||||||
|
|
||||||
<AuthSwitchPrompt lead="Already have an account?" to="/login" label="Sign in" />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'Layout/AuthSplitLayout',
|
|
||||||
component: AuthSplitLayout,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
parameters: {
|
|
||||||
layout: 'fullscreen',
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component: authSplitLayoutDescription,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies Meta<typeof AuthSplitLayout>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
const loginArgs = {
|
|
||||||
headline: 'Good to see you again.',
|
|
||||||
blurb: 'Sign in to pick up where you left off with our partner breweries.',
|
|
||||||
};
|
|
||||||
|
|
||||||
const registerArgs = {
|
|
||||||
headline: 'Pull up a chair.',
|
|
||||||
blurb: 'Create an account to explore partner breweries and their beers.',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Login: Story = {
|
|
||||||
args: {
|
|
||||||
...loginArgs,
|
|
||||||
children: <LoginPanel />,
|
|
||||||
},
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
await expect(canvas.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
|
|
||||||
await expect(canvas.getByRole('button', { name: /show password/i })).toBeInTheDocument();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LoginWithServerError: Story = {
|
|
||||||
args: {
|
|
||||||
...loginArgs,
|
|
||||||
children: <LoginPanel error="Invalid username or password." />,
|
|
||||||
},
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
await expect(canvas.getByRole('alert')).toHaveTextContent('Invalid username or password.');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Register: Story = {
|
|
||||||
args: {
|
|
||||||
...registerArgs,
|
|
||||||
children: <RegisterPanel />,
|
|
||||||
},
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
await expect(canvas.getByText(/step 1 of 3 · account/i)).toBeInTheDocument();
|
|
||||||
await expect(canvas.getByRole('button', { name: /continue/i })).toBeInTheDocument();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RegisterFinalStep: Story = {
|
|
||||||
args: Register.args,
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
await userEvent.type(canvas.getByLabelText(/^username$/i), 'jane_doe');
|
|
||||||
await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example.com');
|
|
||||||
await userEvent.click(canvas.getByRole('button', { name: /continue/i }));
|
|
||||||
|
|
||||||
await userEvent.type(await canvas.findByLabelText(/first name/i), 'Jane');
|
|
||||||
await userEvent.type(canvas.getByLabelText(/last name/i), 'Doe');
|
|
||||||
await fireEvent.change(canvas.getByLabelText(/date of birth/i), {
|
|
||||||
target: { value: '2000-01-15' },
|
|
||||||
});
|
|
||||||
await userEvent.click(canvas.getByRole('button', { name: /continue/i }));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
await canvas.findByRole('heading', { name: 'Secure your account' }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
await expect(canvas.getByRole('button', { name: /create account/i })).toBeInTheDocument();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RegisterMobile: Story = {
|
|
||||||
args: Register.args,
|
|
||||||
parameters: {
|
|
||||||
viewport: {
|
|
||||||
defaultViewport: 'mobile1',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import barBackground from '../../../assets/bar-background.jpg';
|
|
||||||
|
|
||||||
interface AuthSplitLayoutProps {
|
|
||||||
headline: string;
|
|
||||||
blurb: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthSplitLayout = ({ headline, blurb, children }: AuthSplitLayoutProps) => (
|
|
||||||
<div className="relative flex flex-1 bg-base-200">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-base-300 bg-cover bg-center"
|
|
||||||
style={{ backgroundImage: `url(${barBackground})` }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Darkens the photo from the bottom up so the white hero text stays legible */}
|
|
||||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(0deg,#000000b3_30%,#ffffff44_100%)]" />
|
|
||||||
|
|
||||||
<div className="relative mx-auto grid w-full max-w-7xl grid-cols-1 content-between gap-6 min-[820px]:grid-cols-[minmax(0,6fr)_minmax(0,5fr)]">
|
|
||||||
<section className="flex flex-col justify-end gap-4 px-5 pt-8 pb-2 text-white min-[820px]:px-10 min-[820px]:py-14">
|
|
||||||
<h2 className="text-[2rem] leading-[1.1] text-balance [text-shadow:0_2px_24px_#0006] min-[820px]:text-[3.25rem]">
|
|
||||||
{headline}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p className="max-w-[27.5rem] text-base leading-[1.5] min-[820px]:text-[1.1875rem]">
|
|
||||||
{blurb}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<main className="flex items-center justify-center px-4 pt-4 pb-7 min-[820px]:py-10 min-[820px]:pr-10 min-[820px]:pl-0">
|
|
||||||
<div className="flex w-full max-w-[27.5rem] flex-col gap-4 rounded-box bg-base-100 px-5 py-6 shadow-[0_20px_50px_#0005] min-[820px]:p-8">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default AuthSplitLayout;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Link } from 'react-router';
|
|
||||||
|
|
||||||
interface AuthSwitchPromptProps {
|
|
||||||
lead: string;
|
|
||||||
to: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthSwitchPrompt = ({ lead, to, label }: AuthSwitchPromptProps) => (
|
|
||||||
<p className="text-center text-[0.9375rem] text-[var(--color-muted)]">
|
|
||||||
{lead}{' '}
|
|
||||||
<Link to={to} className="font-semibold text-secondary hover:text-primary">
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default AuthSwitchPrompt;
|
|
||||||
@@ -10,7 +10,7 @@ const ConfirmSuccess = ({ confirmedDate }: ConfirmSuccessProps) => (
|
|||||||
<h1 className="card-title text-2xl">Email Confirmed!</h1>
|
<h1 className="card-title text-2xl">Email Confirmed!</h1>
|
||||||
<p className="text-base-content/70">Your email address has been successfully verified.</p>
|
<p className="text-base-content/70">Your email address has been successfully verified.</p>
|
||||||
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
|
<div className="bg-base-200 rounded-box w-full p-3 text-sm text-left">
|
||||||
<span className="text-base-content/70 text-xs uppercase tracking-widest font-semibold">
|
<span className="text-base-content/50 text-xs uppercase tracking-widest font-semibold">
|
||||||
Confirmed at
|
Confirmed at
|
||||||
</span>
|
</span>
|
||||||
<p className="font-mono mt-1">{new Date(confirmedDate).toLocaleString()}</p>
|
<p className="font-mono mt-1">{new Date(confirmedDate).toLocaleString()}</p>
|
||||||
|
|||||||
18
web/frontend/app/features/auth/components/LoginCallout.tsx
Normal file
18
web/frontend/app/features/auth/components/LoginCallout.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
const LoginCallout = () => (
|
||||||
|
<>
|
||||||
|
<div className="divider text-xs">Already have an account?</div>
|
||||||
|
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<Link to="/login" className="btn btn-outline btn-sm w-full">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link to="/" className="link link-hover text-sm text-base-content/60">
|
||||||
|
Back to home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default LoginCallout;
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { BaseSyntheticEvent, useState } from 'react';
|
import { BaseSyntheticEvent } from 'react';
|
||||||
import { FormState, UseFormRegister } from 'react-hook-form';
|
import { FormState, UseFormRegister } from 'react-hook-form';
|
||||||
import FormField from '../../../components/ui/forms/FormField';
|
import FormField from '../../../components/ui/forms/FormField';
|
||||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||||
import type { LoginSchema } from '../schemas';
|
import type { LoginSchema } from '../schemas';
|
||||||
import ShowPasswordToggle from './ShowPasswordToggle';
|
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
onSubmit: (e?: BaseSyntheticEvent) => Promise<Awaited<void> | undefined>;
|
onSubmit: (e?: BaseSyntheticEvent) => Promise<Awaited<void> | undefined>;
|
||||||
@@ -12,15 +11,13 @@ interface LoginFormProps {
|
|||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoginForm = (props: LoginFormProps) => {
|
const LoginForm = (props: LoginFormProps) => (
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
<form onSubmit={props.onSubmit} className="space-y-3">
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={props.onSubmit} className="flex flex-col gap-4">
|
|
||||||
<FormField
|
<FormField
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
placeholder="your_username"
|
||||||
label="Username"
|
label="Username"
|
||||||
error={props.formState.errors.username?.message}
|
error={props.formState.errors.username?.message}
|
||||||
{...props.register('username')}
|
{...props.register('username')}
|
||||||
@@ -28,26 +25,20 @@ const LoginForm = (props: LoginFormProps) => {
|
|||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
id="password"
|
id="password"
|
||||||
type={showPassword ? 'text' : 'password'}
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
placeholder="••••••••"
|
||||||
label="Password"
|
label="Password"
|
||||||
error={props.formState.errors.password?.message}
|
error={props.formState.errors.password?.message}
|
||||||
{...props.register('password')}
|
{...props.register('password')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ShowPasswordToggle
|
|
||||||
shown={showPassword}
|
|
||||||
onToggle={() => setShowPassword((shown) => !shown)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
isSubmitting={props.submitting}
|
isSubmitting={props.submitting}
|
||||||
idleText="Sign in"
|
idleText="Sign In"
|
||||||
submittingText="Signing in…"
|
submittingText="Signing in..."
|
||||||
className="btn btn-primary min-h-12 w-full"
|
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
export default LoginForm;
|
export default LoginForm;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { HomeSimpleDoor, UserPlus } from 'iconoir-react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
const RegisterCallout = () => (
|
||||||
|
<>
|
||||||
|
<div className="divider text-xs">New here?</div>
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<Link to="/register" className="btn btn-outline btn-sm w-full gap-2">
|
||||||
|
<UserPlus className="size-4" aria-hidden="true" />
|
||||||
|
Create an account
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="link link-hover text-sm text-base-content/60 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<HomeSimpleDoor className="size-4" aria-hidden="true" />
|
||||||
|
Back to home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RegisterCallout;
|
||||||
@@ -1,30 +1,25 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
import { expect, fireEvent, userEvent, within } from 'storybook/test';
|
import { expect, fireEvent, userEvent, within } from 'storybook/test';
|
||||||
import AuthCardHeading from './AuthCardHeading';
|
|
||||||
import RegisterForm from './RegisterForm';
|
import RegisterForm from './RegisterForm';
|
||||||
import RegisterStepProgress from './RegisterStepProgress';
|
import { registerSchema, type RegisterSchema } from '../schemas';
|
||||||
import { useRegisterWizard } from '../hooks/useRegisterWizard';
|
|
||||||
import { type RegisterSchema } from '../schemas';
|
|
||||||
|
|
||||||
const registerFormDescription = `Renders the real \`RegisterForm\` driven by the real \`useRegisterWizard\` hook, so these stories exercise the production three-step flow: each step validates its own fields through \`registerSchema\` (zod) before the next one opens, entered values survive going back, and only the final step submits.`;
|
const registerFormDescription = `Renders the real \`RegisterForm\` component wired to the real \`registerSchema\` (zod) through the same react-hook-form setup used on the register page, including its cross-field rule that the confirmation must match the password, so these stories validate actual client-side validation behavior rather than a re-implemented form.`;
|
||||||
|
|
||||||
const RegisterFormHarness = () => {
|
const RegisterFormHarness = () => {
|
||||||
const [submitted, setSubmitted] = useState<RegisterSchema | null>(null);
|
const [submitted, setSubmitted] = useState<RegisterSchema | null>(null);
|
||||||
const wizard = useRegisterWizard(setSubmitted);
|
const form = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-md flex-col gap-4 rounded-box bg-base-100 p-6 shadow-lg">
|
<div className="w-full max-w-lg space-y-3 rounded-box bg-base-100 p-6 shadow-lg">
|
||||||
<RegisterStepProgress
|
<RegisterForm
|
||||||
step={wizard.step}
|
onSubmit={form.handleSubmit((values) => setSubmitted(values))}
|
||||||
totalSteps={wizard.totalSteps}
|
formState={form.formState}
|
||||||
name={wizard.name}
|
register={form.register}
|
||||||
|
submitting={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuthCardHeading title={wizard.title} subtitle={wizard.subtitle} />
|
|
||||||
|
|
||||||
<RegisterForm wizard={wizard} submitting={false} />
|
|
||||||
|
|
||||||
{submitted ? <p data-testid="submit-result">Registered {submitted.username}</p> : null}
|
{submitted ? <p data-testid="submit-result">Registered {submitted.username}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -47,120 +42,75 @@ const meta = {
|
|||||||
export default meta;
|
export default meta;
|
||||||
type Story = StoryObj<typeof meta>;
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
type Canvas = ReturnType<typeof within>;
|
|
||||||
|
|
||||||
const adultDateOfBirth = '2000-01-15';
|
|
||||||
|
|
||||||
const underageDateOfBirth = () => {
|
|
||||||
const tenYearsAgo = new Date();
|
|
||||||
tenYearsAgo.setFullYear(tenYearsAgo.getFullYear() - 10);
|
|
||||||
return tenYearsAgo.toISOString().slice(0, 10);
|
|
||||||
};
|
|
||||||
|
|
||||||
const completeAccountStep = async (canvas: Canvas) => {
|
|
||||||
await userEvent.type(canvas.getByLabelText(/^username$/i), 'jane_doe');
|
|
||||||
await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example.com');
|
|
||||||
await userEvent.click(canvas.getByRole('button', { name: /continue/i }));
|
|
||||||
await canvas.findByLabelText(/first name/i);
|
|
||||||
};
|
|
||||||
|
|
||||||
const completeDetailsStep = async (canvas: Canvas, dateOfBirth = adultDateOfBirth) => {
|
|
||||||
await userEvent.type(canvas.getByLabelText(/first name/i), 'Jane');
|
|
||||||
await userEvent.type(canvas.getByLabelText(/last name/i), 'Doe');
|
|
||||||
await fireEvent.change(canvas.getByLabelText(/date of birth/i), {
|
|
||||||
target: { value: dateOfBirth },
|
|
||||||
});
|
|
||||||
await userEvent.click(canvas.getByRole('button', { name: /continue/i }));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Empty: Story = {};
|
export const Empty: Story = {};
|
||||||
|
|
||||||
export const BlocksAdvanceOnInvalidAccount: Story = {
|
const fillValidRegistration = async (canvas: ReturnType<typeof within>) => {
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
await userEvent.click(canvas.getByRole('button', { name: /continue/i }));
|
|
||||||
await expect(
|
|
||||||
await canvas.findByText(/username must be at least 3 characters/i),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
await expect(canvas.getByText(/invalid email address/i)).toBeInTheDocument();
|
|
||||||
await expect(canvas.getByText(/step 1 of 3/i)).toBeInTheDocument();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ClearsFieldErrorOnEdit: Story = {
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
await userEvent.click(canvas.getByRole('button', { name: /continue/i }));
|
|
||||||
await canvas.findByText(/username must be at least 3 characters/i);
|
|
||||||
|
|
||||||
await userEvent.type(canvas.getByLabelText(/^username$/i), 'jane_doe');
|
await userEvent.type(canvas.getByLabelText(/^username$/i), 'jane_doe');
|
||||||
|
await userEvent.type(canvas.getByLabelText(/first name/i), 'Jane');
|
||||||
|
await userEvent.type(canvas.getByLabelText(/last name/i), 'Doe');
|
||||||
|
await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example.com');
|
||||||
|
await fireEvent.change(canvas.getByLabelText(/date of birth/i), {
|
||||||
|
target: { value: '2000-01-15' },
|
||||||
|
});
|
||||||
|
await userEvent.type(canvas.getByLabelText('Password'), 'StrongPass1!');
|
||||||
|
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'StrongPass1!');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RejectsEmptySubmission: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
||||||
await expect(
|
await expect(
|
||||||
canvas.queryByText(/username must be at least 3 characters/i),
|
canvas.getByText(/username must be at least 3 characters/i),
|
||||||
).not.toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText(/first name is required/i)).toBeInTheDocument();
|
||||||
|
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdvancesToPersonalDetails: Story = {
|
export const RejectsInvalidEmail: Story = {
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
await completeAccountStep(canvas);
|
await fillValidRegistration(canvas);
|
||||||
await expect(canvas.getByRole('heading', { name: 'About you' })).toBeInTheDocument();
|
await userEvent.clear(canvas.getByLabelText(/^email$/i));
|
||||||
await expect(canvas.getByText(/step 2 of 3 · personal details/i)).toBeInTheDocument();
|
await userEvent.type(canvas.getByLabelText(/^email$/i), 'jane@example');
|
||||||
|
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
||||||
|
await expect(canvas.getByText(/invalid email address/i)).toBeInTheDocument();
|
||||||
|
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KeepsValuesWhenGoingBack: Story = {
|
export const RejectsWeakPassword: Story = {
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
await completeAccountStep(canvas);
|
await fillValidRegistration(canvas);
|
||||||
await userEvent.click(canvas.getByRole('button', { name: /back/i }));
|
await userEvent.clear(canvas.getByLabelText('Password'));
|
||||||
await expect(await canvas.findByLabelText(/^username$/i)).toHaveValue('jane_doe');
|
await userEvent.type(canvas.getByLabelText('Password'), 'weakpassword');
|
||||||
await expect(canvas.getByLabelText(/^email$/i)).toHaveValue('jane@example.com');
|
await userEvent.clear(canvas.getByLabelText(/confirm password/i));
|
||||||
},
|
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'weakpassword');
|
||||||
};
|
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
||||||
|
await expect(canvas.getByText(/uppercase letter/i)).toBeInTheDocument();
|
||||||
export const RejectsUnderageDateOfBirth: Story = {
|
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
await completeAccountStep(canvas);
|
|
||||||
await completeDetailsStep(canvas, underageDateOfBirth());
|
|
||||||
await expect(await canvas.findByText(/at least 19 years old/i)).toBeInTheDocument();
|
|
||||||
await expect(canvas.getByText(/step 2 of 3/i)).toBeInTheDocument();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RejectsMismatchedConfirmPassword: Story = {
|
export const RejectsMismatchedConfirmPassword: Story = {
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
await completeAccountStep(canvas);
|
await fillValidRegistration(canvas);
|
||||||
await completeDetailsStep(canvas);
|
await userEvent.clear(canvas.getByLabelText(/confirm password/i));
|
||||||
await userEvent.type(await canvas.findByLabelText('Password'), 'StrongPass1!');
|
|
||||||
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'Different1!');
|
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'Different1!');
|
||||||
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
||||||
await expect(await canvas.findByText(/passwords must match/i)).toBeInTheDocument();
|
await expect(canvas.getByText(/passwords must match/i)).toBeInTheDocument();
|
||||||
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
|
await expect(canvas.queryByTestId('submit-result')).not.toBeInTheDocument();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RevealsBothPasswords: Story = {
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
await completeAccountStep(canvas);
|
|
||||||
await completeDetailsStep(canvas);
|
|
||||||
await userEvent.click(await canvas.findByRole('button', { name: /show password/i }));
|
|
||||||
await expect(canvas.getByLabelText('Password')).toHaveAttribute('type', 'text');
|
|
||||||
await expect(canvas.getByLabelText(/confirm password/i)).toHaveAttribute('type', 'text');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AcceptsValidRegistration: Story = {
|
export const AcceptsValidRegistration: Story = {
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
await completeAccountStep(canvas);
|
await fillValidRegistration(canvas);
|
||||||
await completeDetailsStep(canvas);
|
|
||||||
await userEvent.type(await canvas.findByLabelText('Password'), 'StrongPass1!');
|
|
||||||
await userEvent.type(canvas.getByLabelText(/confirm password/i), 'StrongPass1!');
|
|
||||||
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
await userEvent.click(canvas.getByRole('button', { name: /create account/i }));
|
||||||
await expect(await canvas.findByTestId('submit-result')).toHaveTextContent('jane_doe');
|
await expect(canvas.getByTestId('submit-result')).toHaveTextContent('jane_doe');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,129 +1,97 @@
|
|||||||
import { Button } from '@headlessui/react';
|
import { BaseSyntheticEvent } from 'react';
|
||||||
import { useState } from 'react';
|
import { FormState, UseFormRegister } from 'react-hook-form';
|
||||||
import FormField from '../../../components/ui/forms/FormField';
|
import FormField from '../../../components/ui/forms/FormField';
|
||||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||||
import type { RegisterWizard } from '../hooks/useRegisterWizard';
|
import type { RegisterSchema } from '../schemas';
|
||||||
import { minimumAgeYears } from '../schemas';
|
|
||||||
import ShowPasswordToggle from './ShowPasswordToggle';
|
|
||||||
|
|
||||||
interface RegisterFormProps {
|
interface RegisterFormProps {
|
||||||
wizard: RegisterWizard;
|
onSubmit: (e?: BaseSyntheticEvent) => Promise<Awaited<void> | undefined>;
|
||||||
|
formState: FormState<RegisterSchema>;
|
||||||
|
register: UseFormRegister<RegisterSchema>;
|
||||||
submitting: boolean;
|
submitting: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RegisterForm = ({ wizard, submitting }: RegisterFormProps) => {
|
const RegisterForm = (props: RegisterFormProps) => (
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
<form onSubmit={props.onSubmit} className="space-y-3">
|
||||||
const { step, isLastStep, canGoBack, register, formState } = wizard;
|
|
||||||
const { errors } = formState;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={wizard.onSubmit} className="flex flex-col gap-4" noValidate>
|
|
||||||
{step === 1 && (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<FormField
|
<FormField
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
placeholder="your_username"
|
||||||
label="Username"
|
label="Username"
|
||||||
hint="3-64 characters, alphanumeric and . _ -"
|
hint="3-64 characters, alphanumeric and . _ -"
|
||||||
error={errors.username?.message}
|
error={props.formState.errors.username?.message}
|
||||||
{...register('username')}
|
{...props.register('username')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<div className="grid grid-cols-2 gap-3">
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
label="Email"
|
|
||||||
error={errors.email?.message}
|
|
||||||
{...register('email')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="grid grid-cols-1 gap-3.5 min-[420px]:grid-cols-2">
|
|
||||||
<FormField
|
<FormField
|
||||||
id="firstName"
|
id="firstName"
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="given-name"
|
autoComplete="given-name"
|
||||||
label="First name"
|
placeholder="Jane"
|
||||||
error={errors.firstName?.message}
|
label="First Name"
|
||||||
{...register('firstName')}
|
error={props.formState.errors.firstName?.message}
|
||||||
|
{...props.register('firstName')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
id="lastName"
|
id="lastName"
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="family-name"
|
autoComplete="family-name"
|
||||||
label="Last name"
|
placeholder="Doe"
|
||||||
error={errors.lastName?.message}
|
label="Last Name"
|
||||||
{...register('lastName')}
|
error={props.formState.errors.lastName?.message}
|
||||||
|
{...props.register('lastName')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="jane@example.com"
|
||||||
|
label="Email"
|
||||||
|
error={props.formState.errors.email?.message}
|
||||||
|
{...props.register('email')}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
id="dateOfBirth"
|
id="dateOfBirth"
|
||||||
type="date"
|
type="date"
|
||||||
label="Date of birth"
|
label="Date of Birth"
|
||||||
hint={`Must be ${minimumAgeYears} years or older.`}
|
hint="Must be 19 years or older"
|
||||||
error={errors.dateOfBirth?.message}
|
error={props.formState.errors.dateOfBirth?.message}
|
||||||
{...register('dateOfBirth')}
|
{...props.register('dateOfBirth')}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 3 && (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<FormField
|
<FormField
|
||||||
id="password"
|
id="password"
|
||||||
type={showPassword ? 'text' : 'password'}
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
placeholder="••••••••"
|
||||||
label="Password"
|
label="Password"
|
||||||
hint="8+ chars: uppercase, lowercase, digit, special character"
|
hint="8+ chars: uppercase, lowercase, digit, special character"
|
||||||
error={errors.password?.message}
|
error={props.formState.errors.password?.message}
|
||||||
{...register('password')}
|
{...props.register('password')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type={showPassword ? 'text' : 'password'}
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
label="Confirm password"
|
placeholder="••••••••"
|
||||||
error={errors.confirmPassword?.message}
|
label="Confirm Password"
|
||||||
{...register('confirmPassword')}
|
error={props.formState.errors.confirmPassword?.message}
|
||||||
|
{...props.register('confirmPassword')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ShowPasswordToggle
|
|
||||||
shown={showPassword}
|
|
||||||
onToggle={() => setShowPassword((shown) => !shown)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{canGoBack && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={wizard.back}
|
|
||||||
className="btn btn-outline min-h-12"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
isSubmitting={submitting}
|
isSubmitting={props.submitting}
|
||||||
idleText={isLastStep ? 'Create account' : 'Continue'}
|
idleText="Create Account"
|
||||||
submittingText="Creating account…"
|
submittingText="Creating account..."
|
||||||
className="btn btn-primary min-h-12 w-full"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
export default RegisterForm;
|
export default RegisterForm;
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
interface RegisterStepProgressProps {
|
|
||||||
step: number;
|
|
||||||
totalSteps: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RegisterStepProgress = ({ step, totalSteps, name }: RegisterStepProgressProps) => (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<div className="flex gap-1.5" aria-hidden="true">
|
|
||||||
{Array.from({ length: totalSteps }, (_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`h-1 flex-1 rounded ${index < step ? 'bg-primary' : 'bg-base-300'}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-[0.8125rem] font-semibold text-[var(--color-muted)]">
|
|
||||||
Step {step} of {totalSteps} · {name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default RegisterStepProgress;
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Button } from '@headlessui/react';
|
|
||||||
|
|
||||||
interface ShowPasswordToggleProps {
|
|
||||||
shown: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ShowPasswordToggle = ({ shown, onToggle }: ShowPasswordToggleProps) => (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={onToggle}
|
|
||||||
className="inline-flex min-h-11 items-center self-start text-[0.9375rem] font-semibold text-secondary hover:underline"
|
|
||||||
>
|
|
||||||
{shown ? 'Hide password' : 'Show password'}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default ShowPasswordToggle;
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useForm, type FieldErrors, type Resolver } from 'react-hook-form';
|
|
||||||
import { earliestStepForFields, lastRegisterStepIndex, registerSteps } from '../registerSteps';
|
|
||||||
import { registerSchema, type RegisterSchema } from '../schemas';
|
|
||||||
|
|
||||||
const resolveRegisterSchema = zodResolver(registerSchema);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drives the multi-step register form: one react-hook-form instance spans every
|
|
||||||
* step so values survive going back and forward, and each step validates its own
|
|
||||||
* fields before the next one opens.
|
|
||||||
*/
|
|
||||||
export const useRegisterWizard = (onValid: (values: RegisterSchema) => void) => {
|
|
||||||
const [stepIndex, setStepIndex] = useState(0);
|
|
||||||
|
|
||||||
const currentStep = registerSteps[stepIndex];
|
|
||||||
const isLastStep = stepIndex === lastRegisterStepIndex;
|
|
||||||
|
|
||||||
// Every step submits through `handleSubmit`, so each one has to report only its
|
|
||||||
// own fields: resolve the whole schema, then keep the errors this step owns. The
|
|
||||||
// last step keeps all of them, which is also what runs the password-match
|
|
||||||
// refinement across steps.
|
|
||||||
const resolver: Resolver<RegisterSchema> = async (values, context, options) => {
|
|
||||||
const result = await resolveRegisterSchema(values, context, options);
|
|
||||||
if (isLastStep) return result;
|
|
||||||
|
|
||||||
const errors = Object.fromEntries(
|
|
||||||
Object.entries(result.errors).filter(([name]) =>
|
|
||||||
currentStep.fields.includes(name as keyof RegisterSchema),
|
|
||||||
),
|
|
||||||
) as FieldErrors<RegisterSchema>;
|
|
||||||
|
|
||||||
return Object.keys(errors).length ? { values: {}, errors } : { values, errors: {} };
|
|
||||||
};
|
|
||||||
|
|
||||||
const { register, handleSubmit, formState, setFocus } = useForm<RegisterSchema>({
|
|
||||||
resolver,
|
|
||||||
mode: 'onTouched',
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = handleSubmit(
|
|
||||||
(values) => {
|
|
||||||
if (isLastStep) {
|
|
||||||
onValid(values);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStepIndex((index) => index + 1);
|
|
||||||
},
|
|
||||||
// The last step validates fields from earlier steps too, so open whichever
|
|
||||||
// step owns the problem rather than reporting it out of sight.
|
|
||||||
(errors: FieldErrors<RegisterSchema>) => {
|
|
||||||
const step = earliestStepForFields(Object.keys(errors) as (keyof RegisterSchema)[]);
|
|
||||||
if (step !== null) setStepIndex(step);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Move focus into each new step, which otherwise swaps out from under the
|
|
||||||
// keyboard while focus sits on the button that opened it.
|
|
||||||
const isInitialStep = useRef(true);
|
|
||||||
useEffect(() => {
|
|
||||||
if (isInitialStep.current) {
|
|
||||||
isInitialStep.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [firstField] = registerSteps[stepIndex].fields;
|
|
||||||
if (firstField) setFocus(firstField);
|
|
||||||
}, [stepIndex, setFocus]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
step: stepIndex + 1,
|
|
||||||
totalSteps: registerSteps.length,
|
|
||||||
name: currentStep.name,
|
|
||||||
title: currentStep.title,
|
|
||||||
subtitle: currentStep.subtitle,
|
|
||||||
isLastStep,
|
|
||||||
canGoBack: stepIndex > 0,
|
|
||||||
back: () => setStepIndex((index) => Math.max(index - 1, 0)),
|
|
||||||
onSubmit,
|
|
||||||
register,
|
|
||||||
formState,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RegisterWizard = ReturnType<typeof useRegisterWizard>;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { z } from 'zod';
|
|
||||||
import { registerFields, type RegisterSchema } from './schemas';
|
|
||||||
|
|
||||||
interface RegisterStep {
|
|
||||||
name: string;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
/** The slice of the register schema this step collects. */
|
|
||||||
schema: z.ZodObject;
|
|
||||||
fields: readonly (keyof RegisterSchema)[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const defineStep = (step: Omit<RegisterStep, 'fields'>): RegisterStep => ({
|
|
||||||
...step,
|
|
||||||
fields: Object.keys(step.schema.shape) as (keyof RegisterSchema)[],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const registerSteps: readonly RegisterStep[] = [
|
|
||||||
defineStep({
|
|
||||||
name: 'Account',
|
|
||||||
title: 'Create your account',
|
|
||||||
subtitle: 'Choose how you will sign in.',
|
|
||||||
schema: registerFields.pick({ username: true, email: true }),
|
|
||||||
}),
|
|
||||||
defineStep({
|
|
||||||
name: 'Personal details',
|
|
||||||
title: 'About you',
|
|
||||||
subtitle: 'Tell us a little about yourself.',
|
|
||||||
schema: registerFields.pick({ firstName: true, lastName: true, dateOfBirth: true }),
|
|
||||||
}),
|
|
||||||
defineStep({
|
|
||||||
name: 'Password',
|
|
||||||
title: 'Secure your account',
|
|
||||||
subtitle: 'Pick a strong password.',
|
|
||||||
schema: registerFields.pick({ password: true, confirmPassword: true }),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
export const lastRegisterStepIndex = registerSteps.length - 1;
|
|
||||||
|
|
||||||
/** The step a field belongs to, so an error can open the step that owns it. */
|
|
||||||
export const stepIndexForField = (field: keyof RegisterSchema) =>
|
|
||||||
registerSteps.findIndex((step) => step.fields.includes(field));
|
|
||||||
|
|
||||||
/** The earliest step owning any of `fields`, or `null` when none of them do. */
|
|
||||||
export const earliestStepForFields = (fields: readonly (keyof RegisterSchema)[]) => {
|
|
||||||
const indexes = fields.map(stepIndexForField).filter((index) => index >= 0);
|
|
||||||
return indexes.length ? Math.min(...indexes) : null;
|
|
||||||
};
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { LogIn } from 'iconoir-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { redirect, useNavigation, useSubmit } from 'react-router';
|
import { redirect, useNavigation, useSubmit } from 'react-router';
|
||||||
import { createAuthSession, getOptionalAuth, login } from '../auth.server';
|
import { createAuthSession, getOptionalAuth, login } from '../auth.server';
|
||||||
import AuthCardHeading from '../components/AuthCardHeading';
|
|
||||||
import AuthErrorAlert from '../components/AuthErrorAlert';
|
|
||||||
import AuthSplitLayout from '../components/AuthSplitLayout';
|
|
||||||
import AuthSwitchPrompt from '../components/AuthSwitchPrompt';
|
|
||||||
import LoginForm from '../components/LoginForm';
|
import LoginForm from '../components/LoginForm';
|
||||||
|
import RegisterCallout from '../components/RegisterCallout';
|
||||||
import { useActionErrorToast } from '../hooks/useActionErrorToast';
|
import { useActionErrorToast } from '../hooks/useActionErrorToast';
|
||||||
import { loginSchema, type LoginSchema } from '../schemas';
|
import { loginSchema, type LoginSchema } from '../schemas';
|
||||||
import type { Route } from './+types/login';
|
import type { Route } from './+types/login';
|
||||||
@@ -56,13 +54,22 @@ const Login = ({ actionData }: Route.ComponentProps) => {
|
|||||||
useActionErrorToast(actionData?.error);
|
useActionErrorToast(actionData?.error);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthSplitLayout
|
<div className="hero min-h-screen bg-base-200">
|
||||||
headline="Good to see you again."
|
<div className="card w-full max-w-md bg-base-100 shadow-xl">
|
||||||
blurb="Sign in to pick up where you left off with our partner breweries."
|
<div className="card-body gap-4">
|
||||||
>
|
<div className="text-center">
|
||||||
<AuthCardHeading title="Sign in" subtitle="Sign in to your Biergarten account" />
|
<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 && <AuthErrorAlert message={actionData.error} />}
|
{actionData?.error && (
|
||||||
|
<div role="alert" className="alert alert-error alert-soft">
|
||||||
|
<span>{actionData.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<LoginForm
|
<LoginForm
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
@@ -71,8 +78,10 @@ const Login = ({ actionData }: Route.ComponentProps) => {
|
|||||||
submitting={isSubmitting}
|
submitting={isSubmitting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AuthSwitchPrompt lead="New to Biergarten?" to="/register" label="Create an account" />
|
<RegisterCallout />
|
||||||
</AuthSplitLayout>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
import { redirect, useNavigation, useSubmit } from 'react-router';
|
import { redirect, useNavigation, useSubmit } from 'react-router';
|
||||||
import { z } from 'zod';
|
|
||||||
import { createAuthSession, getOptionalAuth, register } from '../auth.server';
|
import { createAuthSession, getOptionalAuth, register } from '../auth.server';
|
||||||
import AuthCardHeading from '../components/AuthCardHeading';
|
import LoginCallout from '../components/LoginCallout';
|
||||||
import AuthErrorAlert from '../components/AuthErrorAlert';
|
|
||||||
import AuthSplitLayout from '../components/AuthSplitLayout';
|
|
||||||
import AuthSwitchPrompt from '../components/AuthSwitchPrompt';
|
|
||||||
import RegisterForm from '../components/RegisterForm';
|
import RegisterForm from '../components/RegisterForm';
|
||||||
import RegisterStepProgress from '../components/RegisterStepProgress';
|
|
||||||
import { useActionErrorToast } from '../hooks/useActionErrorToast';
|
import { useActionErrorToast } from '../hooks/useActionErrorToast';
|
||||||
import { useRegisterWizard } from '../hooks/useRegisterWizard';
|
import { registerSchema, type RegisterSchema } from '../schemas';
|
||||||
import { registerSchema } from '../schemas';
|
|
||||||
import type { Route } from './+types/register';
|
import type { Route } from './+types/register';
|
||||||
|
|
||||||
export const meta = ({}: Route.MetaArgs) => {
|
export const meta = ({}: Route.MetaArgs) => {
|
||||||
@@ -24,14 +20,33 @@ export const loader = async ({ request }: Route.LoaderArgs) => {
|
|||||||
|
|
||||||
export const action = async ({ request }: Route.ActionArgs) => {
|
export const action = async ({ request }: Route.ActionArgs) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const result = registerSchema.safeParse(Object.fromEntries(formData));
|
const result = registerSchema.safeParse({
|
||||||
|
username: formData.get('username'),
|
||||||
|
firstName: formData.get('firstName'),
|
||||||
|
lastName: formData.get('lastName'),
|
||||||
|
email: formData.get('email'),
|
||||||
|
dateOfBirth: formData.get('dateOfBirth'),
|
||||||
|
password: formData.get('password'),
|
||||||
|
confirmPassword: formData.get('confirmPassword'),
|
||||||
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return { error: null, fieldErrors: z.flattenError(result.error).fieldErrors };
|
const fieldErrors = result.error.flatten().fieldErrors as Record<
|
||||||
|
keyof RegisterSchema,
|
||||||
|
string[] | undefined
|
||||||
|
>;
|
||||||
|
return { error: null, fieldErrors };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { confirmPassword: _confirmPassword, ...body } = result.data;
|
const body = {
|
||||||
|
username: result.data.username,
|
||||||
|
firstName: result.data.firstName,
|
||||||
|
lastName: result.data.lastName,
|
||||||
|
email: result.data.email,
|
||||||
|
dateOfBirth: result.data.dateOfBirth,
|
||||||
|
password: result.data.password,
|
||||||
|
};
|
||||||
const payload = await register(body);
|
const payload = await register(body);
|
||||||
return createAuthSession(payload, '/dashboard');
|
return createAuthSession(payload, '/dashboard');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -47,44 +62,44 @@ const Register = ({ actionData }: Route.ComponentProps) => {
|
|||||||
const submit = useSubmit();
|
const submit = useSubmit();
|
||||||
const isSubmitting = navigation.state === 'submitting';
|
const isSubmitting = navigation.state === 'submitting';
|
||||||
|
|
||||||
const wizard = useRegisterWizard((data) => {
|
const {
|
||||||
|
register: field,
|
||||||
|
handleSubmit,
|
||||||
|
formState,
|
||||||
|
} = useForm<RegisterSchema>({ resolver: zodResolver(registerSchema) });
|
||||||
|
|
||||||
|
const onSubmit = handleSubmit((data) => {
|
||||||
submit(data, { method: 'post' });
|
submit(data, { method: 'post' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// A request that reached the action without passing client validation, so the
|
|
||||||
// rejected fields may well sit on a step other than the one on screen.
|
|
||||||
const rejectedFields = Object.values(actionData?.fieldErrors ?? {}).flatMap(
|
|
||||||
(messages) => messages ?? [],
|
|
||||||
);
|
|
||||||
|
|
||||||
useActionErrorToast(actionData?.error);
|
useActionErrorToast(actionData?.error);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthSplitLayout
|
<div className="min-h-screen bg-base-200 flex items-center justify-center p-4">
|
||||||
headline="Pull up a chair."
|
<div className="card w-full max-w-lg bg-base-100 shadow-xl">
|
||||||
blurb="Create an account to explore partner breweries and their beers."
|
<div className="card-body gap-4">
|
||||||
>
|
<div className="text-center">
|
||||||
<RegisterStepProgress
|
<h1 className="card-title text-3xl justify-center">Register</h1>
|
||||||
step={wizard.step}
|
<p className="text-base-content/70">Create your Biergarten account</p>
|
||||||
totalSteps={wizard.totalSteps}
|
</div>
|
||||||
name={wizard.name}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AuthCardHeading title={wizard.title} subtitle={wizard.subtitle} />
|
{actionData?.error && (
|
||||||
|
<div role="alert" className="alert alert-error alert-soft">
|
||||||
{actionData?.error && <AuthErrorAlert message={actionData.error} />}
|
<span>{actionData.error}</span>
|
||||||
|
</div>
|
||||||
{rejectedFields.length > 0 && (
|
|
||||||
<AuthErrorAlert
|
|
||||||
message="Check these details before creating your account."
|
|
||||||
details={rejectedFields}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RegisterForm wizard={wizard} submitting={isSubmitting} />
|
<RegisterForm
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
formState={formState}
|
||||||
|
register={field}
|
||||||
|
submitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
|
||||||
<AuthSwitchPrompt lead="Already have an account?" to="/login" label="Sign in" />
|
<LoginCallout />
|
||||||
</AuthSplitLayout>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,53 +7,27 @@ export const loginSchema = z.object({
|
|||||||
|
|
||||||
export type LoginSchema = z.infer<typeof loginSchema>;
|
export type LoginSchema = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
export const minimumAgeYears = 19;
|
export const registerSchema = z
|
||||||
|
.object({
|
||||||
const isOldEnough = (value: string) => {
|
|
||||||
const dateOfBirth = new Date(value);
|
|
||||||
if (Number.isNaN(dateOfBirth.getTime())) return false;
|
|
||||||
|
|
||||||
const comingOfAge = new Date(
|
|
||||||
dateOfBirth.getFullYear() + minimumAgeYears,
|
|
||||||
dateOfBirth.getMonth(),
|
|
||||||
dateOfBirth.getDate(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return comingOfAge <= new Date();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const registerFields = z.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(64, 'Username must be at most 64 characters')
|
.max(20, 'Username must be at most 20 characters'),
|
||||||
.regex(
|
|
||||||
/^[A-Za-z0-9._-]+$/,
|
|
||||||
'Username can only contain letters, numbers, dots, underscores and hyphens',
|
|
||||||
),
|
|
||||||
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.email('Invalid email address'),
|
email: z.string().email('Invalid email address'),
|
||||||
dateOfBirth: z
|
dateOfBirth: z.string().min(1, 'Date of birth is required'),
|
||||||
.string()
|
|
||||||
.min(1, 'Date of birth is required')
|
|
||||||
.refine(isOldEnough, `You must be at least ${minimumAgeYears} years old to register`),
|
|
||||||
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'),
|
||||||
.regex(/[^A-Za-z0-9]/, 'Password must contain a special character'),
|
|
||||||
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||||
});
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
export const registerSchema = registerFields.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>;
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ const BeerListCard = ({ beers }: BeerListCardProps) => (
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-0.5 min-w-0">
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
<span className="font-bold text-sm">{beer.name}</span>
|
<span className="font-bold text-sm">{beer.name}</span>
|
||||||
<span className="text-xs text-base-content/70">{beer.description}</span>
|
<span className="text-xs text-base-content/60">{beer.description}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="badge badge-ghost bg-base-300 text-xs font-semibold whitespace-nowrap">
|
<span className="badge badge-ghost bg-base-300 text-xs font-semibold whitespace-nowrap">
|
||||||
{beer.style}
|
{beer.style}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-base-content/70 tabular-nums whitespace-nowrap">
|
<span className="text-xs text-base-content/60 tabular-nums whitespace-nowrap">
|
||||||
{beer.abv.toFixed(1)}% ABV
|
{beer.abv.toFixed(1)}% ABV
|
||||||
</span>
|
</span>
|
||||||
<StarRating value={beer.rating} size="xs" />
|
<StarRating value={beer.rating} size="xs" />
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const BeerStyleBreakdownCard = ({ beers }: BeerStyleBreakdownCardProps) => {
|
|||||||
<div key={style} className="flex flex-col gap-1">
|
<div key={style} className="flex flex-col gap-1">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className="text-sm font-semibold">{style}</span>
|
<span className="text-sm font-semibold">{style}</span>
|
||||||
<span className="text-xs text-base-content/70 tabular-nums">{count}</span>
|
<span className="text-xs text-base-content/60 tabular-nums">{count}</span>
|
||||||
</div>
|
</div>
|
||||||
<progress
|
<progress
|
||||||
className="progress progress-primary h-1.5"
|
className="progress progress-primary h-1.5"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface BreweryDetailsCardProps {
|
|||||||
|
|
||||||
const DetailRow = ({ label, value }: { label: string; value: string | number }) => (
|
const DetailRow = ({ label, value }: { label: string; value: string | number }) => (
|
||||||
<div className="flex items-center justify-between gap-2 text-sm">
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
<span className="text-base-content/70">{label}</span>
|
<span className="text-base-content/60">{label}</span>
|
||||||
<span className="font-semibold">{value}</span>
|
<span className="font-semibold">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,18 +26,16 @@ const BreweryHeaderCard = ({
|
|||||||
<div className="card-body gap-4 p-7">
|
<div className="card-body gap-4 p-7">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-xs font-bold uppercase tracking-widest text-base-content/70">
|
<span className="text-xs font-bold uppercase tracking-widest text-base-content/50">
|
||||||
Brewery
|
Brewery
|
||||||
</span>
|
</span>
|
||||||
<span className="badge bg-[var(--color-highlight)] text-[var(--color-highlight-content)]">
|
<span className="badge bg-[var(--color-highlight)] text-[var(--color-highlight-content)]">
|
||||||
Est. {foundedYear}
|
Est. {foundedYear}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-serif text-5xl font-bold leading-tight m-0">
|
<h1 className="text-4xl leading-tight m-0">{brewery.breweryName}</h1>
|
||||||
{brewery.breweryName}
|
|
||||||
</h1>
|
|
||||||
{brewery.location && (
|
{brewery.location && (
|
||||||
<p className="text-sm text-base-content/70 flex items-center gap-1 m-0">
|
<p className="text-sm text-base-content/60 flex items-center gap-1 m-0">
|
||||||
<MapPin className="size-4" aria-hidden="true" />
|
<MapPin className="size-4" aria-hidden="true" />
|
||||||
{formatBreweryAddress(brewery.location)}
|
{formatBreweryAddress(brewery.location)}
|
||||||
</p>
|
</p>
|
||||||
@@ -59,7 +57,7 @@ const BreweryHeaderCard = ({
|
|||||||
<div className="flex items-baseline gap-1.5">
|
<div className="flex items-baseline gap-1.5">
|
||||||
<StarRating value={avgRating} size="sm" />
|
<StarRating value={avgRating} size="sm" />
|
||||||
<span className="font-bold">{avgRating.toFixed(1)}</span>
|
<span className="font-bold">{avgRating.toFixed(1)}</span>
|
||||||
<span className="text-sm text-base-content/70">({ratingsCount} ratings)</span>
|
<span className="text-sm text-base-content/60">({ratingsCount} ratings)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base-content/70 leading-relaxed text-pretty m-0">
|
<p className="text-base-content/70 leading-relaxed text-pretty m-0">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface CommunityStatsCardProps {
|
|||||||
|
|
||||||
const StatRow = ({ label, value }: { label: string; value: number }) => (
|
const StatRow = ({ label, value }: { label: string; value: number }) => (
|
||||||
<div className="flex items-center justify-between gap-2 text-sm">
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
<span className="text-base-content/70">{label}</span>
|
<span className="text-base-content/60">{label}</span>
|
||||||
<span className="font-semibold tabular-nums">{value}</span>
|
<span className="font-semibold tabular-nums">{value}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const YourRatingCard = ({ value, onChange }: YourRatingCardProps) => (
|
|||||||
<div className="card-body flex-row flex-wrap items-center justify-between gap-4 p-6">
|
<div className="card-body flex-row flex-wrap items-center justify-between gap-4 p-6">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<h3 className="text-xl m-0">Your rating</h3>
|
<h3 className="text-xl m-0">Your rating</h3>
|
||||||
<p className="text-sm text-base-content/70 m-0">
|
<p className="text-sm text-base-content/60 m-0">
|
||||||
{value
|
{value
|
||||||
? `You rated this ${value} of 5. Tap the same star to clear.`
|
? `You rated this ${value} of 5. Tap the same star to clear.`
|
||||||
: 'Tap a star to rate this brewery.'}
|
: 'Tap a star to rate this brewery.'}
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ const CommentItem = ({ comment, onToggleLike }: CommentItemProps) => {
|
|||||||
<span className="font-bold text-sm">{comment.user}</span>
|
<span className="font-bold text-sm">{comment.user}</span>
|
||||||
)}
|
)}
|
||||||
<StarRating value={comment.rating} size="xs" />
|
<StarRating value={comment.rating} size="xs" />
|
||||||
<span className="text-xs text-base-content/70">{comment.time}</span>
|
<span className="text-xs text-base-content/60">{comment.time}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-relaxed m-0">{comment.text}</p>
|
<p className="text-sm leading-relaxed m-0">{comment.text}</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggleLike(comment.id)}
|
onClick={() => onToggleLike(comment.id)}
|
||||||
className={`self-start inline-flex items-center gap-1 text-xs font-semibold ${
|
className={`self-start inline-flex items-center gap-1 text-xs font-semibold ${
|
||||||
comment.liked ? 'text-primary' : 'text-base-content/70'
|
comment.liked ? 'text-primary' : 'text-base-content/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{comment.liked ? (
|
{comment.liked ? (
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const BreweryCard = ({ brewery }: BreweryCardProps) => (
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{brewery.location && (
|
{brewery.location && (
|
||||||
<p className="text-sm text-base-content/70 mt-2">
|
<p className="text-sm text-base-content/50 mt-2">
|
||||||
{formatBreweryAddress(brewery.location)}
|
{formatBreweryAddress(brewery.location)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ const FeaturedBreweryCard = ({ brewery, onShowOnMap }: FeaturedBreweryCardProps)
|
|||||||
<div className="card-body gap-3.5 p-8 md:grid md:grid-cols-[1.35fr_1fr] md:gap-10 md:items-start">
|
<div className="card-body gap-3.5 p-8 md:grid md:grid-cols-[1.35fr_1fr] md:gap-10 md:items-start">
|
||||||
<div className="flex flex-col gap-3.5">
|
<div className="flex flex-col gap-3.5">
|
||||||
{brewery.location && (
|
{brewery.location && (
|
||||||
<div className="text-sm font-semibold text-base-content/70">
|
<div className="text-sm font-semibold text-base-content/60">
|
||||||
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
|
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h3 className="font-serif text-4xl leading-tight m-0">{brewery.breweryName}</h3>
|
<h3 className="font-serif text-4xl leading-tight m-0">{brewery.breweryName}</h3>
|
||||||
<p className="text-lg leading-snug text-pretty m-0">{brewery.description}</p>
|
<p className="text-lg leading-snug text-pretty m-0">{brewery.description}</p>
|
||||||
{brewery.location && (
|
{brewery.location && (
|
||||||
<p className="text-sm text-base-content/70 m-0">
|
<p className="text-sm text-base-content/60 m-0">
|
||||||
{formatBreweryAddress(brewery.location)}
|
{formatBreweryAddress(brewery.location)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const RecentBreweries = ({ breweries }: RecentBreweriesProps) => {
|
|||||||
<div className="card-body gap-1.5">
|
<div className="card-body gap-1.5">
|
||||||
<h3 className="card-title font-serif text-xl">{brewery.breweryName}</h3>
|
<h3 className="card-title font-serif text-xl">{brewery.breweryName}</h3>
|
||||||
{brewery.location && (
|
{brewery.location && (
|
||||||
<div className="text-sm text-base-content/70">
|
<div className="text-sm text-base-content/60">
|
||||||
{brewery.location.cityName},{' '}
|
{brewery.location.cityName},{' '}
|
||||||
{brewery.location.stateProvinceCode}
|
{brewery.location.stateProvinceCode}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const NearbyBreweriesHeader = ({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-3.5">
|
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-3.5">
|
||||||
<h2 className="font-serif text-2xl m-0">Breweries near you</h2>
|
<h2 className="font-serif text-2xl m-0">Breweries near you</h2>
|
||||||
<span className="text-sm text-base-content/70">
|
<span className="text-sm text-base-content/60">
|
||||||
{center
|
{center
|
||||||
? `${withDistance.length} within ${formatDistance(radiusKm * 1000, unit)} of ${centerLabel}`
|
? `${withDistance.length} within ${formatDistance(radiusKm * 1000, unit)} of ${centerLabel}`
|
||||||
: 'Finding breweries near you…'}
|
: 'Finding breweries near you…'}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const NearbyBreweryList = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{withDistance.length === 0 && center && (
|
{withDistance.length === 0 && center && (
|
||||||
<p className="text-sm text-base-content/70">
|
<p className="text-sm text-base-content/60">
|
||||||
No partner breweries within {formatDistance(radiusKm * 1000, unit)}.
|
No partner breweries within {formatDistance(radiusKm * 1000, unit)}.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const NearbyBreweryListItem = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{brewery.location && (
|
{brewery.location && (
|
||||||
<div className="text-sm text-base-content/70">
|
<div className="text-sm text-base-content/60">
|
||||||
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
|
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface SidebarCardProps {
|
|||||||
const SidebarCard = ({ title, children }: SidebarCardProps) => (
|
const SidebarCard = ({ title, children }: SidebarCardProps) => (
|
||||||
<div className="card bg-base-100 shadow">
|
<div className="card bg-base-100 shadow">
|
||||||
<div className="card-body gap-3 p-6">
|
<div className="card-body gap-3 p-6">
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-base-content/70 m-0">
|
<p className="text-xs font-bold uppercase tracking-widest text-base-content/50 m-0">
|
||||||
{title}
|
{title}
|
||||||
</p>
|
</p>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200 text-base-content pb-16">
|
<div className="min-h-screen bg-base-200 text-base-content pb-16">
|
||||||
<div className="max-w-7xl mx-auto px-5 pt-10">
|
<div className="max-w-7xl mx-auto px-5 pt-10">
|
||||||
<h1 className="font-serif text-5xl font-bold leading-tight mb-2">Breweries</h1>
|
<h1 className="font-serif text-5xl leading-tight mb-2">Breweries</h1>
|
||||||
<p className="text-lg text-base-content/70 max-w-xl m-0">
|
<p className="text-lg text-base-content/60 max-w-xl m-0">
|
||||||
Discover our partner breweries — start with this week's feature, then see
|
Discover our partner breweries — start with this week's feature, then see
|
||||||
what's pouring near you.
|
what's pouring near you.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -80,15 +80,15 @@ const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="mx-auto max-w-7xl px-5 pt-10 pb-16">
|
<div className="max-w-4xl mx-auto px-6 pt-8 pb-16">
|
||||||
<Link
|
<Link
|
||||||
to="/breweries"
|
to="/breweries"
|
||||||
className="link link-hover text-sm text-base-content/70 mb-4 inline-block"
|
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
|
||||||
>
|
>
|
||||||
← Back to breweries
|
← Back to breweries
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[1fr_23rem] gap-5 items-start">
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_19rem] gap-5 items-start">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<BreweryHeaderCard
|
<BreweryHeaderCard
|
||||||
brewery={brewery}
|
brewery={brewery}
|
||||||
|
|||||||
@@ -72,16 +72,14 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="mx-auto max-w-7xl px-5 pt-10">
|
<div className="container mx-auto p-14">
|
||||||
<Link
|
<Link
|
||||||
to="/breweries"
|
to="/breweries"
|
||||||
className="link link-hover text-sm text-base-content/70 mb-4 inline-block"
|
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
|
||||||
>
|
>
|
||||||
← Back to Breweries
|
← Back to Breweries
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="font-serif text-5xl font-bold leading-tight mb-4">
|
<h1 className="text-4xl font-bold mb-4">Full brewery directory</h1>
|
||||||
Full brewery directory
|
|
||||||
</h1>
|
|
||||||
<p className="text-base-content/70 mb-6">
|
<p className="text-base-content/70 mb-6">
|
||||||
All of our partner breweries, newest first.
|
All of our partner breweries, newest first.
|
||||||
</p>
|
</p>
|
||||||
@@ -95,7 +93,7 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
|||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
|
||||||
{allBreweries.length === 0 ? (
|
{allBreweries.length === 0 ? (
|
||||||
<p className="text-center text-base-content/70 mt-12">
|
<p className="text-center text-base-content/60 mt-12">
|
||||||
No breweries found yet.
|
No breweries found yet.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { Clock, Lock, Tools } from 'iconoir-react';
|
|
||||||
import type { ComponentType } from 'react';
|
|
||||||
import { Link } from 'react-router';
|
|
||||||
|
|
||||||
type Reason = 'soon' | 'maintenance' | 'auth';
|
|
||||||
|
|
||||||
interface Action {
|
|
||||||
to: string;
|
|
||||||
label: string;
|
|
||||||
className: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Content {
|
|
||||||
Icon: ComponentType<{ className?: string }>;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
actions: Action[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONTENT: Record<Reason, Content> = {
|
|
||||||
soon: {
|
|
||||||
Icon: Clock,
|
|
||||||
title: 'This feature is not available yet',
|
|
||||||
description: 'This feature is still in development.',
|
|
||||||
actions: [
|
|
||||||
{ to: '/breweries', label: 'Browse breweries', className: 'btn-primary' },
|
|
||||||
{ to: '/', label: 'Back to home', className: 'btn-ghost' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
maintenance: {
|
|
||||||
Icon: Tools,
|
|
||||||
title: 'This feature is temporarily unavailable',
|
|
||||||
description: 'This feature is offline for maintenance. Try again in a few minutes.',
|
|
||||||
actions: [],
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
Icon: Lock,
|
|
||||||
title: 'Sign in to use this feature',
|
|
||||||
description:
|
|
||||||
'This feature requires a valid session. Log in or create an account to continue.',
|
|
||||||
actions: [
|
|
||||||
{ to: '/login', label: 'Login', className: 'btn-primary' },
|
|
||||||
{ to: '/register', label: 'Register', className: 'btn-outline' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FeatureNotAvailableProps {
|
|
||||||
reason?: Reason;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FeatureNotAvailable = ({ reason = 'soon' }: FeatureNotAvailableProps) => {
|
|
||||||
const { Icon, title, description, actions } = CONTENT[reason];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="flex flex-col items-center gap-3 rounded-box border border-base-300 bg-base-100 px-6 py-12 text-center">
|
|
||||||
<div className="flex size-14 items-center justify-center rounded-full bg-base-200 text-base-content/70">
|
|
||||||
<Icon className="size-6" />
|
|
||||||
</div>
|
|
||||||
<div className="flex max-w-md flex-col gap-2">
|
|
||||||
<h2 className="font-serif text-2xl font-bold leading-tight">{title}</h2>
|
|
||||||
<p className="text-base-content/70 text-pretty">{description}</p>
|
|
||||||
</div>
|
|
||||||
{actions.length > 0 && (
|
|
||||||
<div className="flex flex-wrap justify-center gap-3 pt-2">
|
|
||||||
{actions.map(({ to, label, className }) => (
|
|
||||||
<Link key={to} to={to} className={`btn btn-sm ${className}`}>
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FeatureNotAvailable;
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import FeatureNotAvailable from '../components/FeatureNotAvailable';
|
|
||||||
import type { Route } from './+types/beer-styles';
|
import type { Route } from './+types/beer-styles';
|
||||||
|
|
||||||
export const meta = ({}: Route.MetaArgs) => {
|
export const meta = ({}: Route.MetaArgs) => {
|
||||||
@@ -8,12 +7,9 @@ export const meta = ({}: Route.MetaArgs) => {
|
|||||||
const BeerStyles = () => {
|
const BeerStyles = () => {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="mx-auto max-w-7xl px-5 pt-10">
|
<div className="container mx-auto p-4">
|
||||||
<h1 className="font-serif text-5xl font-bold leading-tight mb-4">Beer Styles</h1>
|
<h1 className="text-4xl font-bold mb-4">Beer Styles</h1>
|
||||||
<p className="text-base-content/70">Learn about different beer styles.</p>
|
<p className="text-base-content/70">Learn about different beer styles.</p>
|
||||||
<div className="mt-8">
|
|
||||||
<FeatureNotAvailable />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import FeatureNotAvailable from '../components/FeatureNotAvailable';
|
|
||||||
import type { Route } from './+types/beers';
|
import type { Route } from './+types/beers';
|
||||||
|
|
||||||
export const meta = ({}: Route.MetaArgs) => {
|
export const meta = ({}: Route.MetaArgs) => {
|
||||||
@@ -8,12 +7,9 @@ export const meta = ({}: Route.MetaArgs) => {
|
|||||||
const Beers = () => {
|
const Beers = () => {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="mx-auto max-w-7xl px-5 pt-10">
|
<div className="container mx-auto p-4">
|
||||||
<h1 className="font-serif text-5xl font-bold leading-tight 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 className="mt-8">
|
|
||||||
<FeatureNotAvailable />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 203 KiB |
@@ -3,7 +3,7 @@ import { Link } from 'react-router';
|
|||||||
const ClosingCta = () => (
|
const ClosingCta = () => (
|
||||||
<section className="mx-auto flex max-w-3xl flex-col items-center gap-5 px-5 py-20 text-center">
|
<section className="mx-auto flex max-w-3xl flex-col items-center gap-5 px-5 py-20 text-center">
|
||||||
<h2 className="m-0 font-serif text-[clamp(2rem,4vw,2.75rem)] leading-tight text-balance">
|
<h2 className="m-0 font-serif text-[clamp(2rem,4vw,2.75rem)] leading-tight text-balance">
|
||||||
Your next favourite brewery is on the list.
|
🍺 Your next favourite brewery is on the list.
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="m-0 text-lg leading-[1.55] text-[var(--color-muted)]">
|
<p className="m-0 text-lg leading-[1.55] text-[var(--color-muted)]">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import barBackground from '../../../assets/bar-background.jpg';
|
import barBackground from '../assets/bar-background.jpg';
|
||||||
|
|
||||||
const HeroSection = () => (
|
const HeroSection = () => (
|
||||||
<section className="mx-auto max-w-7xl px-5 pt-18 pb-16">
|
<section className="mx-auto max-w-7xl px-5 pt-18 pb-16">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { expect, within } from 'storybook/test';
|
|||||||
import { biergartenThemes, defaultThemeName } from '../../theme/themes';
|
import { biergartenThemes, defaultThemeName } from '../../theme/themes';
|
||||||
import ThemeTryOut from './ThemeTryOut';
|
import ThemeTryOut from './ThemeTryOut';
|
||||||
|
|
||||||
const themeTryOutDescription = `Landing page theme band. Frames the shared \`ThemeSegmentedControl\` in an elevated card with a "Set the mood" eyebrow and a pour count, and renders it with short labels so the four options fit the marketing page's narrower column. Each option carries its \`shortVibe\` mood line, and picking one switches the whole document theme. The control seeds its selection from the root route's loader data, so this story runs behind a memory data router whose \`root\` route supplies a theme. \`Themes/ThemeSegmentedControl\` covers the control itself.`;
|
const themeTryOutDescription = `Landing page theme band. Hosts the shared \`ThemeSegmentedControl\` with short labels, so a visitor can switch the whole document theme from the marketing page. The control seeds its selection from the root route's loader data, so this story runs behind a memory data router whose \`root\` route supplies a theme.`;
|
||||||
|
|
||||||
const ThemeTryOutHarness = () => {
|
const ThemeTryOutHarness = () => {
|
||||||
const [router] = useState(() =>
|
const [router] = useState(() =>
|
||||||
@@ -48,15 +48,9 @@ export const Default: Story = {
|
|||||||
canvas.getByRole('heading', { name: 'Pick a pour that suits you' }),
|
canvas.getByRole('heading', { name: 'Pick a pour that suits you' }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
await expect(canvas.getByText('Set the mood')).toBeVisible();
|
|
||||||
await expect(canvas.getByText(`${biergartenThemes.length} pours`)).toBeVisible();
|
|
||||||
|
|
||||||
const group = canvas.getByRole('radiogroup', { name: 'Theme selector' });
|
const group = canvas.getByRole('radiogroup', { name: 'Theme selector' });
|
||||||
await expect(within(group).getAllByRole('radio')).toHaveLength(biergartenThemes.length);
|
|
||||||
|
|
||||||
for (const theme of biergartenThemes) {
|
for (const theme of biergartenThemes) {
|
||||||
await expect(within(group).getByText(theme.shortLabel)).toBeVisible();
|
await expect(within(group).getByText(theme.shortLabel)).toBeInTheDocument();
|
||||||
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,17 +13,11 @@ const ThemeTryOut = () => (
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-box border border-neutral-content/15 bg-base-100 p-4 text-base-content shadow-xl sm:p-5">
|
<div className="rounded-box bg-base-100 p-3 text-base-content">
|
||||||
<div className="mb-4 flex items-center justify-between gap-4">
|
<ThemeSegmentedControl
|
||||||
<div>
|
labels="short"
|
||||||
<p className="m-0 text-xs font-bold uppercase tracking-[0.18em] opacity-60">
|
className="join join-vertical w-full sm:join-horizontal"
|
||||||
Set the mood
|
/>
|
||||||
</p>
|
|
||||||
<p className="m-0 mt-1 text-sm opacity-75">Your theme follows your pour.</p>
|
|
||||||
</div>
|
|
||||||
<span className="badge badge-outline shrink-0">4 pours</span>
|
|
||||||
</div>
|
|
||||||
<ThemeSegmentedControl labels="short" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const TextTokenSample = ({
|
|||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-box p-3" style={{ backgroundColor: `var(${background})` }}>
|
<div className="rounded-box p-3" style={{ backgroundColor: `var(${background})` }}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
<p className="text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm font-medium" style={{ color: `var(${text})` }}>
|
<p className="mt-1 text-sm font-medium" style={{ color: `var(${text})` }}>
|
||||||
@@ -54,7 +54,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||||
Core
|
Core
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
@@ -69,7 +69,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||||
Status
|
Status
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4">
|
<div className="grid gap-2 grid-cols-2 sm:grid-cols-4">
|
||||||
@@ -81,7 +81,7 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||||
Content
|
Content
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-2 sm:grid-cols-3">
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
|
|||||||
@@ -1,167 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { createMemoryRouter, RouterProvider } from 'react-router';
|
|
||||||
import { expect, fn, userEvent, within } from 'storybook/test';
|
|
||||||
import { biergartenThemes, defaultThemeName, themeCookieName, type ThemeName } from '../themes';
|
|
||||||
import ThemeSegmentedControl from './ThemeSegmentedControl';
|
|
||||||
|
|
||||||
const themeSegmentedControlDescription = `The shared theme picker, used by the theme guide and by the landing page's try-out band. Each option pairs a gradient swatch of the theme's palette with its name and \`shortVibe\` mood line, and selection is carried by both color and elevation. Picking an option sets \`data-theme\` on \`<html>\` and writes the \`${themeCookieName}\` cookie, so the choice survives the next request and the server renders the same theme. The initial selection comes from the root route's loader, so these stories run behind a memory data router whose \`root\` route supplies the theme the cookie would have provided.`;
|
|
||||||
|
|
||||||
interface HarnessProps {
|
|
||||||
labels?: 'full' | 'short';
|
|
||||||
className?: string;
|
|
||||||
onChange?: (theme: ThemeName) => void;
|
|
||||||
/** Theme the root loader hands the control, standing in for the theme cookie. */
|
|
||||||
seedTheme?: ThemeName;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemeSegmentedControlHarness = ({
|
|
||||||
labels,
|
|
||||||
className,
|
|
||||||
onChange,
|
|
||||||
seedTheme = defaultThemeName,
|
|
||||||
}: HarnessProps) => {
|
|
||||||
// Rebuilt per arg change so the docs controls remount the control rather
|
|
||||||
// than leaving it seeded from the previous render.
|
|
||||||
const router = useMemo(
|
|
||||||
() =>
|
|
||||||
createMemoryRouter([
|
|
||||||
{
|
|
||||||
id: 'root',
|
|
||||||
path: '/',
|
|
||||||
loader: () => ({ theme: seedTheme }),
|
|
||||||
element: (
|
|
||||||
<div className="max-w-md rounded-box bg-base-100 p-4 text-base-content">
|
|
||||||
<ThemeSegmentedControl
|
|
||||||
labels={labels}
|
|
||||||
className={className}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
[labels, className, onChange, seedTheme],
|
|
||||||
);
|
|
||||||
|
|
||||||
return <RouterProvider router={router} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'Themes/ThemeSegmentedControl',
|
|
||||||
component: ThemeSegmentedControlHarness,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
args: {
|
|
||||||
labels: 'full',
|
|
||||||
seedTheme: defaultThemeName,
|
|
||||||
onChange: fn(),
|
|
||||||
},
|
|
||||||
argTypes: {
|
|
||||||
labels: { control: 'inline-radio', options: ['full', 'short'] },
|
|
||||||
seedTheme: {
|
|
||||||
control: 'select',
|
|
||||||
options: biergartenThemes.map((theme) => theme.value),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
parameters: {
|
|
||||||
usesDataRouter: true,
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component: themeSegmentedControlDescription,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies Meta<typeof ThemeSegmentedControlHarness>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
const getOptions = (canvasElement: HTMLElement) => {
|
|
||||||
const group = within(canvasElement).getByRole('radiogroup', { name: 'Theme selector' });
|
|
||||||
return { group, radios: within(group).getAllByRole('radio') as HTMLInputElement[] };
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Full labels, as the theme guide renders it. */
|
|
||||||
export const Default: Story = {
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const { group, radios } = getOptions(canvasElement);
|
|
||||||
|
|
||||||
await expect(radios).toHaveLength(biergartenThemes.length);
|
|
||||||
|
|
||||||
for (const theme of biergartenThemes) {
|
|
||||||
await expect(within(group).getByText(theme.label)).toBeVisible();
|
|
||||||
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
const [lager] = radios;
|
|
||||||
await expect(lager.value).toBe(defaultThemeName);
|
|
||||||
await expect(lager.checked).toBe(true);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Short labels, as the landing page renders it in the narrower try-out card. */
|
|
||||||
export const ShortLabels: Story = {
|
|
||||||
args: {
|
|
||||||
labels: 'short',
|
|
||||||
},
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const { group } = getOptions(canvasElement);
|
|
||||||
|
|
||||||
for (const theme of biergartenThemes) {
|
|
||||||
await expect(within(group).getByText(theme.shortLabel)).toBeVisible();
|
|
||||||
await expect(within(group).getByText(theme.shortVibe)).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(within(group).queryByText(biergartenThemes[0].label)).toBeNull();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** A returning visitor whose cookie already names a theme other than the default. */
|
|
||||||
export const SeededFromCookie: Story = {
|
|
||||||
args: {
|
|
||||||
seedTheme: 'biergarten-cassis',
|
|
||||||
},
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const { radios } = getOptions(canvasElement);
|
|
||||||
|
|
||||||
for (const radio of radios) {
|
|
||||||
await expect(radio.checked).toBe(radio.value === 'biergarten-cassis');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SelectingATheme: Story = {
|
|
||||||
play: async ({ canvasElement, args }) => {
|
|
||||||
const previousTheme = document.documentElement.getAttribute('data-theme');
|
|
||||||
const { group, radios } = getOptions(canvasElement);
|
|
||||||
|
|
||||||
await userEvent.click(within(group).getByText('Biergarten Stout'));
|
|
||||||
|
|
||||||
for (const radio of radios) {
|
|
||||||
await expect(radio.checked).toBe(radio.value === 'biergarten-stout');
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect(args.onChange).toHaveBeenCalledWith('biergarten-stout');
|
|
||||||
await expect(document.documentElement.getAttribute('data-theme')).toBe('biergarten-stout');
|
|
||||||
await expect(document.cookie).toContain(`${themeCookieName}=biergarten-stout`);
|
|
||||||
|
|
||||||
// The control themes the whole document, so hand <html> back to the
|
|
||||||
// toolbar's theme before the next story mounts.
|
|
||||||
if (previousTheme === null) {
|
|
||||||
document.documentElement.removeAttribute('data-theme');
|
|
||||||
} else {
|
|
||||||
document.documentElement.setAttribute('data-theme', previousTheme);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Single column, for a sidebar or any container too narrow for the paired grid. */
|
|
||||||
export const SingleColumn: Story = {
|
|
||||||
args: {
|
|
||||||
className: 'grid grid-cols-1 gap-2',
|
|
||||||
},
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const { radios } = getOptions(canvasElement);
|
|
||||||
await expect(radios).toHaveLength(biergartenThemes.length);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -19,7 +19,7 @@ interface ThemeSegmentedControlProps {
|
|||||||
|
|
||||||
const ThemeSegmentedControl = ({
|
const ThemeSegmentedControl = ({
|
||||||
labels = 'full',
|
labels = 'full',
|
||||||
className = 'grid grid-cols-2 gap-2',
|
className = 'join join-vertical sm:join-horizontal',
|
||||||
onChange,
|
onChange,
|
||||||
}: ThemeSegmentedControlProps) => {
|
}: ThemeSegmentedControlProps) => {
|
||||||
const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName;
|
const rootTheme = useRouteLoaderData<typeof rootLoader>('root')?.theme ?? defaultThemeName;
|
||||||
@@ -33,11 +33,7 @@ const ThemeSegmentedControl = ({
|
|||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={theme.value}
|
key={theme.value}
|
||||||
className={`group flex min-h-20 cursor-pointer items-center gap-3 rounded-box border p-3 text-left transition-all duration-200 ${
|
className={`btn join-item ${checked ? 'btn-primary' : 'btn-outline'}`}
|
||||||
checked
|
|
||||||
? 'border-primary bg-primary text-primary-content shadow-md'
|
|
||||||
: 'border-base-content/15 bg-base-200/45 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-base-200'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -51,34 +47,7 @@ const ThemeSegmentedControl = ({
|
|||||||
onChange?.(theme.value);
|
onChange?.(theme.value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={`size-9 shrink-0 rounded-full border-4 shadow-inner transition-transform duration-200 group-hover:scale-105 ${
|
|
||||||
checked ? 'border-primary-content/70' : 'border-base-100'
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
theme.value === 'biergarten-lager'
|
|
||||||
? 'linear-gradient(135deg, #f5c451 0%, #a96728 100%)'
|
|
||||||
: theme.value === 'biergarten-stout'
|
|
||||||
? 'linear-gradient(135deg, #d69b45 0%, #382116 100%)'
|
|
||||||
: theme.value === 'biergarten-cassis'
|
|
||||||
? 'linear-gradient(135deg, #c777b9 0%, #4c214e 100%)'
|
|
||||||
: 'linear-gradient(135deg, #f7e6a5 0%, #82a85d 100%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0">
|
|
||||||
<span className="block truncate text-sm font-bold">
|
|
||||||
{labels === 'short' ? theme.shortLabel : theme.label}
|
{labels === 'short' ? theme.shortLabel : theme.label}
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`mt-0.5 block truncate text-xs ${
|
|
||||||
checked ? 'text-primary-content/75' : 'text-base-content/60'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{theme.shortVibe}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ export interface ThemeOption {
|
|||||||
label: string;
|
label: string;
|
||||||
/** Used where the segmented control is narrow, such as the landing page. */
|
/** Used where the segmented control is narrow, such as the landing page. */
|
||||||
shortLabel: string;
|
shortLabel: string;
|
||||||
/** A compact mood descriptor for theme pickers. */
|
|
||||||
shortVibe: string;
|
|
||||||
vibe: string;
|
vibe: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,28 +17,24 @@ export const biergartenThemes: ThemeOption[] = [
|
|||||||
value: 'biergarten-lager',
|
value: 'biergarten-lager',
|
||||||
label: 'Biergarten Lager',
|
label: 'Biergarten Lager',
|
||||||
shortLabel: 'Lager',
|
shortLabel: 'Lager',
|
||||||
shortVibe: 'Muted parchment',
|
|
||||||
vibe: 'Muted parchment, mellow amber, daytime beer garden',
|
vibe: 'Muted parchment, mellow amber, daytime beer garden',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'biergarten-stout',
|
value: 'biergarten-stout',
|
||||||
label: 'Biergarten Stout',
|
label: 'Biergarten Stout',
|
||||||
shortLabel: 'Stout',
|
shortLabel: 'Stout',
|
||||||
shortVibe: 'Charred barrel',
|
|
||||||
vibe: 'Charred barrel, deep roast, cozy evening cellar',
|
vibe: 'Charred barrel, deep roast, cozy evening cellar',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'biergarten-cassis',
|
value: 'biergarten-cassis',
|
||||||
label: 'Biergarten Cassis',
|
label: 'Biergarten Cassis',
|
||||||
shortLabel: 'Cassis',
|
shortLabel: 'Cassis',
|
||||||
shortVibe: 'Blackberry barrel',
|
|
||||||
vibe: 'Blackberry barrel, sour berry dark, vivid night market',
|
vibe: 'Blackberry barrel, sour berry dark, vivid night market',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'biergarten-weizen',
|
value: 'biergarten-weizen',
|
||||||
label: 'Biergarten Weizen',
|
label: 'Biergarten Weizen',
|
||||||
shortLabel: 'Weizen',
|
shortLabel: 'Weizen',
|
||||||
shortVibe: 'Bright spring',
|
|
||||||
vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
|
vibe: 'Ultra-light young barley, green undertone, bright spring afternoon',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,18 +6,18 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of
|
|||||||
## Status legend
|
## Status legend
|
||||||
|
|
||||||
- ✅ **Available**: real data, wired up.
|
- ✅ **Available**: real data, wired up.
|
||||||
- ⚠️ **Partial**: reads real data, but a related gap remains (see the Gap column).
|
- ⚠️ **Partial**: schema/repository exists but no public read endpoint.
|
||||||
- ❌ **Missing**: no backend support; page shows filler or local-only state.
|
- ❌ **Missing**: no backend support; page shows filler or local-only state.
|
||||||
|
|
||||||
## Header & sidebar
|
## Header & sidebar
|
||||||
|
|
||||||
| Field | Status | Source | Gap |
|
| Field | Status | Source | Gap |
|
||||||
| ---------------------------------------- | ------ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------- | ------ | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Username, name, joined date | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | |
|
| Username, name, joined date | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | |
|
||||||
| `isOwnProfile` | ✅ | Compares the session's `userAccountId` to the route param | |
|
| `isOwnProfile` | ✅ | Compares the session's `userAccountId` to the route param | |
|
||||||
| Bio | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | Reads `Social.UserProfile.Biography` via the left join in `UserListRepository.GetByIdAsync`; an account with no profile row yet returns an empty string. |
|
| Bio | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | Reads `Social.UserProfile.Biography` via the left join in `UserListRepository.GetByIdAsync`; an account with no profile row yet returns an empty string. |
|
||||||
| Location | ❌ | `FILLER_PROFILE_META.location` | No location field on `UserAccount` or `UserProfile`. |
|
| Location | ❌ | `FILLER_PROFILE_META.location` | No location field on `UserAccount` or `UserProfile`. |
|
||||||
| Avatar photo | ⚠️ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | Reads the current `Social.UserAvatar` photo via the left joins in `UserListRepository.GetByIdAsync` and signs it into `avatarUrl`; an account with no avatar returns an empty string and `ProfileAvatar` shows initials. `UploadAvatarCommand` has no HTTP route yet, so only accounts created by `Database.Seed` have one — see (4) below. |
|
| Avatar photo | ⚠️ | Initials placeholder (`avatar-placeholder`) | `Social.UserAvatar` + `UploadAvatarHandler` exist but there's no GET endpoint to read a user's stored avatar back. |
|
||||||
| Cover photo | ❌ | Flat `bg-base-300` block | No cover-photo concept anywhere in the schema. |
|
| Cover photo | ❌ | Flat `bg-base-300` block | No cover-photo concept anywhere in the schema. |
|
||||||
| Following / Followers counts | ❌ | `FILLER_PROFILE_META.following` / `.followers` | `Social.UserFollow` table exists but is unused by any query/controller — needs count queries (`WHERE UserAccountID = ?` / `WHERE FollowingID = ?`). |
|
| Following / Followers counts | ❌ | `FILLER_PROFILE_META.following` / `.followers` | `Social.UserFollow` table exists but is unused by any query/controller — needs count queries (`WHERE UserAccountID = ?` / `WHERE FollowingID = ?`). |
|
||||||
| Breweries visited / Ratings given counts | ❌ | `FILLER_PROFILE_META.breweriesVisited` / `.ratingsGiven` | Blocked on the same like/rating gaps called out in `BREWERY_HANDOFF.md`. |
|
| Breweries visited / Ratings given counts | ❌ | `FILLER_PROFILE_META.breweriesVisited` / `.ratingsGiven` | Blocked on the same like/rating gaps called out in `BREWERY_HANDOFF.md`. |
|
||||||
@@ -36,7 +36,7 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of
|
|||||||
| Commenter name links (brewery detail comments) | ⚠️ | `CommentItem` links only when `comment.userAccountId` is set | `FILLER_COMMENTS` has no backing accounts, so those rows render as plain text; a comment added during the session by a signed-in user does carry a real GUID and links correctly. |
|
| Commenter name links (brewery detail comments) | ⚠️ | `CommentItem` links only when `comment.userAccountId` is set | `FILLER_COMMENTS` has no backing accounts, so those rows render as plain text; a comment added during the session by a signed-in user does carry a real GUID and links correctly. |
|
||||||
| Following-list person links (profile page) | ⚠️ | `FollowingTab` links only when `f.userAccountId` is set | Same gap as above — `FILLER_FOLLOWING`'s two person rows have no backing accounts, so they render as plain text until real follow data exists. |
|
| Following-list person links (profile page) | ⚠️ | `FollowingTab` links only when `f.userAccountId` is set | Same gap as above — `FILLER_FOLLOWING`'s two person rows have no backing accounts, so they render as plain text until real follow data exists. |
|
||||||
|
|
||||||
Both components already support linking (see `CommentItem.stories.tsx`'s `WithLinkedAccount` story); what's missing is real account IDs on the filler rows, which can't be faked without recreating the 404 this was fixed for. This resolves itself once (3) below closes and comments/follows are loader-backed.
|
Both components already support linking (see `CommentItem.stories.tsx`'s `WithLinkedAccount` story); what's missing is real account IDs on the filler rows, which can't be faked without recreating the 404 this was fixed for. This resolves itself once (4) below closes and comments/follows are loader-backed.
|
||||||
|
|
||||||
## Activity / Following / Liked tabs
|
## Activity / Following / Liked tabs
|
||||||
|
|
||||||
@@ -49,8 +49,8 @@ Both components already support linking (see `CommentItem.stories.tsx`'s `WithLi
|
|||||||
|
|
||||||
## Summary of backend work
|
## Summary of backend work
|
||||||
|
|
||||||
1. A location field on `UserAccount` or `UserProfile`, and a cover-photo storage concept.
|
1. A public query + controller route to read a user's stored `Social.UserAvatar`.
|
||||||
2. `Social.UserFollow` command/query/controller: follow, unfollow, "is following" check, and follower/following counts.
|
2. A location field on `UserAccount` or `UserProfile`, and a cover-photo storage concept (mirrors the avatar gap).
|
||||||
3. An activity/timeline query unioning ratings, likes, and comments for a user, ordered by time — depends on the like/rating/comment tables called out in `BREWERY_HANDOFF.md` existing first.
|
3. `Social.UserFollow` command/query/controller: follow, unfollow, "is following" check, and follower/following counts.
|
||||||
4. An avatar-upload endpoint: `UploadAvatarCommand`/`UploadAvatarHandler` exist and `Database.Seed` sends them, but no controller action does, so a signed-in user has no way to set their own avatar. Needs a `multipart/form-data` route on the authenticated caller's own account (`IFormFile`, same validation the seeder's uploads pass through).
|
4. An activity/timeline query unioning ratings, likes, and comments for a user, ordered by time — depends on the like/rating/comment tables called out in `BREWERY_HANDOFF.md` existing first.
|
||||||
5. Once (2) and the like/rating gaps close, swap `FILLER_PROFILE_META` / `FILLER_ACTIVITY` / `FILLER_FOLLOWING` / `FILLER_LIKED` (`utils/filler-user-profile.ts`) and the local `useState` in `routes/user-profile.tsx` for loader data and `authorizedRequest`-based mutations (see `auth.server.ts`). Swapping in real (possibly empty) arrays is also what makes the Activity/Following `EmptyState`s reachable from the actual route — today they're only exercised by the `Empty` Storybook stories, since the filler arrays are never empty.
|
5. Once (3) and the like/rating gaps close, swap `FILLER_PROFILE_META` / `FILLER_ACTIVITY` / `FILLER_FOLLOWING` / `FILLER_LIKED` (`utils/filler-user-profile.ts`) and the local `useState` in `routes/user-profile.tsx` for loader data and `authorizedRequest`-based mutations (see `auth.server.ts`). Swapping in real (possibly empty) arrays is also what makes the Activity/Following `EmptyState`s reachable from the actual route — today they're only exercised by the `Empty` Storybook stories, since the filler arrays are never empty.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const EmptyState = ({ message }: { message: string }) => (
|
const EmptyState = ({ message }: { message: string }) => (
|
||||||
<div className="card bg-base-100 shadow">
|
<div className="card bg-base-100 shadow">
|
||||||
<div className="card-body items-center py-12">
|
<div className="card-body items-center py-12">
|
||||||
<p className="text-sm text-base-content/70 text-center m-0">{message}</p>
|
<p className="text-sm text-base-content/60 text-center m-0">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
||||||
import { expect, within } from 'storybook/test';
|
|
||||||
import ProfileAvatar from './ProfileAvatar';
|
|
||||||
|
|
||||||
const profileAvatarDescription = `Avatar shown at the top of the public profile sidebar. It renders the photo from the public profile payload's \`avatarUrl\`, and shows the first two letters of the display name when that field is empty or the image fails to load.`;
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'Users/ProfileAvatar',
|
|
||||||
component: ProfileAvatar,
|
|
||||||
tags: ['autodocs'],
|
|
||||||
args: {
|
|
||||||
displayName: 'Maren Iverson',
|
|
||||||
avatarUrl: 'https://picsum.photos/id/1027/240/240',
|
|
||||||
},
|
|
||||||
parameters: {
|
|
||||||
layout: 'centered',
|
|
||||||
docs: {
|
|
||||||
description: {
|
|
||||||
component: profileAvatarDescription,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies Meta<typeof ProfileAvatar>;
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof meta>;
|
|
||||||
|
|
||||||
export const WithPhoto: Story = {
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
const image = canvas.getByRole('img', { name: "Maren Iverson's avatar" });
|
|
||||||
await expect(image).toHaveAttribute('src', 'https://picsum.photos/id/1027/240/240');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WithoutPhoto: Story = {
|
|
||||||
args: { avatarUrl: '' },
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
await expect(canvas.getByText('MA')).toBeInTheDocument();
|
|
||||||
await expect(canvas.queryByRole('img')).not.toBeInTheDocument();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BrokenPhotoUrl: Story = {
|
|
||||||
args: { avatarUrl: 'https://example.invalid/missing-avatar.jpg' },
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
await expect(await canvas.findByText('MA')).toBeInTheDocument();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
interface ProfileAvatarProps {
|
|
||||||
avatarUrl: string;
|
|
||||||
displayName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the user's current avatar photo, falling back to their initials when the public
|
|
||||||
* profile payload carries an empty `avatarUrl` or the hosted image fails to load.
|
|
||||||
*/
|
|
||||||
const ProfileAvatar = ({ avatarUrl, displayName }: ProfileAvatarProps) => {
|
|
||||||
const [didImageFail, setDidImageFail] = useState(false);
|
|
||||||
const showPhoto = avatarUrl.length > 0 && !didImageFail;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`avatar w-[120px] h-[120px] rounded-full border-4 border-base-200 shadow-md ${
|
|
||||||
showPhoto ? '' : 'avatar-placeholder'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{showPhoto ? (
|
|
||||||
<div className="w-full h-full rounded-full overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={avatarUrl}
|
|
||||||
alt={`${displayName}'s avatar`}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
onError={() => setDidImageFail(true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-secondary text-secondary-content w-full h-full rounded-full flex items-center justify-center">
|
|
||||||
<span className="text-2xl font-bold">
|
|
||||||
{displayName.slice(0, 2).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProfileAvatar;
|
|
||||||
@@ -33,7 +33,7 @@ const ProfileSidebarCard = ({
|
|||||||
<div className="card-body gap-4 p-6">
|
<div className="card-body gap-4 p-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold m-0 leading-tight">{displayName}</h1>
|
<h1 className="text-2xl font-bold m-0 leading-tight">{displayName}</h1>
|
||||||
<p className="text-sm text-base-content/70 m-0 mt-0.5">@{username}</p>
|
<p className="text-sm text-base-content/60 m-0 mt-0.5">@{username}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isOwnProfile ? (
|
{isOwnProfile ? (
|
||||||
@@ -52,7 +52,7 @@ const ProfileSidebarCard = ({
|
|||||||
|
|
||||||
<p className="text-sm leading-normal m-0">{bio}</p>
|
<p className="text-sm leading-normal m-0">{bio}</p>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5 text-sm text-base-content/70">
|
<div className="flex flex-col gap-1.5 text-sm text-base-content/60">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<MapPin className="size-4 shrink-0" aria-hidden="true" />
|
<MapPin className="size-4 shrink-0" aria-hidden="true" />
|
||||||
{location}
|
{location}
|
||||||
@@ -68,7 +68,7 @@ const ProfileSidebarCard = ({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
<div key={stat.label} className="flex items-center justify-between gap-2">
|
<div key={stat.label} className="flex items-center justify-between gap-2">
|
||||||
<span className="text-sm text-base-content/70">{stat.label}</span>
|
<span className="text-sm text-base-content/60">{stat.label}</span>
|
||||||
<span className="font-serif text-lg font-bold tabular-nums">
|
<span className="font-serif text-lg font-bold tabular-nums">
|
||||||
{stat.value}
|
{stat.value}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const ProfileTabs = ({ activeTab, onChange, panels }: ProfileTabsProps) => {
|
|||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<Tab
|
<Tab
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
className="-mb-px border-b-2 border-transparent py-3 text-sm font-semibold text-base-content/70 transition-colors hover:text-base-content focus:outline-none data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-primary data-[selected]:border-primary data-[selected]:text-primary"
|
className="-mb-px border-b-2 border-transparent py-3 text-sm font-semibold text-base-content/60 transition-colors hover:text-base-content focus:outline-none data-[focus]:outline-2 data-[focus]:outline-offset-2 data-[focus]:outline-primary data-[selected]:border-primary data-[selected]:text-primary"
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ const ActivityTab = ({ activity }: ActivityTabProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.comment && (
|
{item.comment && (
|
||||||
<p className="text-sm text-base-content/70 italic mt-1.5 mb-0">
|
<p className="text-sm text-base-content/60 italic mt-1.5 mb-0">
|
||||||
“{item.comment}”
|
“{item.comment}”
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-base-content/70 mt-1.5 mb-0">
|
<p className="text-xs text-base-content/60 mt-1.5 mb-0">
|
||||||
{item.time}
|
{item.time}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const FollowingTab = ({ following, onToggleFollow }: FollowingTabProps) => {
|
|||||||
) : (
|
) : (
|
||||||
<p className="font-semibold text-sm m-0 truncate">{f.name}</p>
|
<p className="font-semibold text-sm m-0 truncate">{f.name}</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-base-content/70 m-0 truncate">{f.meta}</p>
|
<p className="text-xs text-base-content/60 m-0 truncate">{f.meta}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const LikedTab = ({ liked }: LikedTabProps) => {
|
|||||||
<div className="h-[120px] bg-base-300" />
|
<div className="h-[120px] bg-base-300" />
|
||||||
<div className="card-body p-4 gap-1">
|
<div className="card-body p-4 gap-1">
|
||||||
<p className="font-serif font-bold m-0">{item.name}</p>
|
<p className="font-serif font-bold m-0">{item.name}</p>
|
||||||
<p className="text-sm text-base-content/70 m-0">{item.meta}</p>
|
<p className="text-sm text-base-content/60 m-0">{item.meta}</p>
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<StarRating value={item.rating} size="xs" />
|
<StarRating value={item.rating} size="xs" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { data } from 'react-router';
|
|||||||
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
||||||
import { getOptionalAuth } from '../../auth/auth.server';
|
import { getOptionalAuth } from '../../auth/auth.server';
|
||||||
import { getPublicUserProfile, type PublicUserProfile } from '../users.server';
|
import { getPublicUserProfile, type PublicUserProfile } from '../users.server';
|
||||||
import ProfileAvatar from '../components/profile/ProfileAvatar';
|
|
||||||
import ProfileSidebarCard from '../components/profile/ProfileSidebarCard';
|
import ProfileSidebarCard from '../components/profile/ProfileSidebarCard';
|
||||||
import ProfileTabs, { type ProfileTab } from '../components/profile/ProfileTabs';
|
import ProfileTabs, { type ProfileTab } from '../components/profile/ProfileTabs';
|
||||||
import ActivityTab from '../components/profile/tabs/ActivityTab';
|
import ActivityTab from '../components/profile/tabs/ActivityTab';
|
||||||
@@ -76,12 +75,20 @@ const ProfileContent = ({ profile, isOwnProfile }: ProfileContentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200 pb-16">
|
<div className="min-h-screen bg-base-200 pb-16">
|
||||||
<div className="mx-auto max-w-7xl px-5 pt-10">
|
<div className="max-w-[1080px] mx-auto px-6 pt-8">
|
||||||
<div className="rounded-box h-[190px] bg-base-300" />
|
<div className="rounded-box h-[190px] bg-base-300" />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-8 items-start -mt-12">
|
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-8 items-start -mt-12">
|
||||||
<div className="lg:sticky lg:top-8 flex flex-col gap-5 px-2">
|
<div className="lg:sticky lg:top-8 flex flex-col gap-5 px-2">
|
||||||
<ProfileAvatar avatarUrl={profile.avatarUrl} displayName={displayName} />
|
<div className="w-[120px] h-[120px] rounded-full border-4 border-base-200 shadow-md overflow-hidden">
|
||||||
|
<div className="avatar avatar-placeholder w-full h-full">
|
||||||
|
<div className="bg-secondary text-secondary-content w-full h-full flex items-center justify-center">
|
||||||
|
<span className="text-2xl font-bold">
|
||||||
|
{displayName.slice(0, 2).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ProfileSidebarCard
|
<ProfileSidebarCard
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export interface PublicUserProfile {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
biography: string;
|
biography: string;
|
||||||
avatarUrl: string;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +49,6 @@ export const getPublicUserProfile = async (
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
biography: string;
|
biography: string;
|
||||||
avatarUrl: string;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
} = await res.json();
|
} = await res.json();
|
||||||
|
|
||||||
@@ -60,7 +58,6 @@ export const getPublicUserProfile = async (
|
|||||||
firstName: account.firstName,
|
firstName: account.firstName,
|
||||||
lastName: account.lastName,
|
lastName: account.lastName,
|
||||||
biography: account.biography,
|
biography: account.biography,
|
||||||
avatarUrl: account.avatarUrl,
|
|
||||||
createdAt: account.createdAt,
|
createdAt: account.createdAt,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// see USER_PROFILE_HANDOFF.md — filler data pending backend support for
|
// see USER_PROFILE_HANDOFF.md — filler data pending backend support for
|
||||||
// location, cover photos, follower/following stats, and activity feeds.
|
// bio, location, avatar/cover photos, follower/following stats, and activity feeds.
|
||||||
|
|
||||||
export interface FillerProfileMeta {
|
export interface FillerProfileMeta {
|
||||||
bio: string;
|
bio: string;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user