Commit
Build Tasteprint travel food passport
commit
d7d35fc
169 changed files with +15213 and −0
Jump to a changed file
- .editorconfig +15 −0
- .env.example +5 −0
- .gitattributes +3 −0
- .github/workflows/ci.yml +45 −0
- .gitignore +15 −0
- README.md +138 −0
- backend/.dockerignore +5 −0
- backend/.mvn/wrapper/maven-wrapper.properties +3 −0
- backend/Dockerfile +16 −0
- backend/mvnw +295 −0
- backend/mvnw.cmd +189 −0
- backend/pom.xml +144 −0
- backend/src/main/java/com/tasteprint/TasteprintApplication.java +13 −0
- backend/src/main/java/com/tasteprint/account/Account.java +131 −0
- backend/src/main/java/com/tasteprint/account/AccountDeletionRequested.java +6 −0
- backend/src/main/java/com/tasteprint/account/AccountRepository.java +15 −0
- backend/src/main/java/com/tasteprint/account/AccountService.java +214 −0
- backend/src/main/java/com/tasteprint/account/AccountView.java +18 −0
- backend/src/main/java/com/tasteprint/account/AuthController.java +69 −0
- backend/src/main/java/com/tasteprint/account/AuthSession.java +4 −0
- backend/src/main/java/com/tasteprint/account/AuthenticatedUser.java +6 −0
- backend/src/main/java/com/tasteprint/account/DeleteAccountRequest.java +9 −0
- backend/src/main/java/com/tasteprint/account/LoginRequest.java +11 −0
- backend/src/main/java/com/tasteprint/account/PublicAccountView.java +16 −0
- backend/src/main/java/com/tasteprint/account/RegisterRequest.java +12 −0
- backend/src/main/java/com/tasteprint/account/SecurityConfiguration.java +89 −0
- backend/src/main/java/com/tasteprint/account/SessionToken.java +44 −0
- backend/src/main/java/com/tasteprint/account/SessionTokenRepository.java +16 −0
- backend/src/main/java/com/tasteprint/account/TokenAuthenticationFilter.java +49 −0
- backend/src/main/java/com/tasteprint/account/UpdateProfileRequest.java +15 −0
- backend/src/main/java/com/tasteprint/account/package-info.java +5 −0
- backend/src/main/java/com/tasteprint/catalog/CatalogController.java +40 −0
- backend/src/main/java/com/tasteprint/catalog/CatalogService.java +123 −0
- backend/src/main/java/com/tasteprint/catalog/Destination.java +74 −0
- backend/src/main/java/com/tasteprint/catalog/DestinationDetails.java +6 −0
- backend/src/main/java/com/tasteprint/catalog/DestinationRepository.java +10 −0
- backend/src/main/java/com/tasteprint/catalog/DestinationSummary.java +14 −0
- backend/src/main/java/com/tasteprint/catalog/Dish.java +98 −0
- backend/src/main/java/com/tasteprint/catalog/DishCategory.java +20 −0
- backend/src/main/java/com/tasteprint/catalog/DishRepository.java +21 −0
- backend/src/main/java/com/tasteprint/catalog/DishView.java +19 −0
- backend/src/main/java/com/tasteprint/catalog/package-info.java +5 −0
- backend/src/main/java/com/tasteprint/demo/DemoDataInitializer.java +120 −0
- backend/src/main/java/com/tasteprint/demo/package-info.java +5 −0
- backend/src/main/java/com/tasteprint/journey/MissionItemView.java +6 −0
- backend/src/main/java/com/tasteprint/journey/SaveTripRequest.java +27 −0
- backend/src/main/java/com/tasteprint/journey/Trip.java +81 −0
- backend/src/main/java/com/tasteprint/journey/TripController.java +61 −0
- backend/src/main/java/com/tasteprint/journey/TripMissionItem.java +43 −0
- backend/src/main/java/com/tasteprint/journey/TripMissionItemRepository.java +11 −0
- backend/src/main/java/com/tasteprint/journey/TripRepository.java +16 −0
- backend/src/main/java/com/tasteprint/journey/TripService.java +184 −0
- backend/src/main/java/com/tasteprint/journey/TripStatus.java +7 −0
- backend/src/main/java/com/tasteprint/journey/TripView.java +20 −0
- backend/src/main/java/com/tasteprint/journey/package-info.java +5 −0
- backend/src/main/java/com/tasteprint/media/LocalMediaStorage.java +147 −0
- backend/src/main/java/com/tasteprint/media/MediaAccountDeletionListener.java +21 −0
- backend/src/main/java/com/tasteprint/media/MediaAsset.java +52 −0
- backend/src/main/java/com/tasteprint/media/MediaAssetRepository.java +16 −0
- backend/src/main/java/com/tasteprint/media/MediaController.java +30 −0
- backend/src/main/java/com/tasteprint/media/MediaStorage.java +16 −0
- backend/src/main/java/com/tasteprint/media/MediaUpload.java +4 −0
- backend/src/main/java/com/tasteprint/media/MediaWebConfiguration.java +24 −0
- backend/src/main/java/com/tasteprint/media/package-info.java +5 −0
- backend/src/main/java/com/tasteprint/progress/DashboardView.java +11 −0
- backend/src/main/java/com/tasteprint/progress/DestinationProgress.java +11 −0
- backend/src/main/java/com/tasteprint/progress/DestinationProgressDetails.java +9 −0
- backend/src/main/java/com/tasteprint/progress/DishProgress.java +6 −0
- backend/src/main/java/com/tasteprint/progress/ProgressController.java +31 −0
- backend/src/main/java/com/tasteprint/progress/ProgressService.java +176 −0
- backend/src/main/java/com/tasteprint/progress/TasteSnapshot.java +14 −0
- backend/src/main/java/com/tasteprint/progress/TasteStats.java +10 −0
- backend/src/main/java/com/tasteprint/progress/package-info.java +5 −0
- backend/src/main/java/com/tasteprint/shared/ApiExceptionHandler.java +71 −0
- backend/src/main/java/com/tasteprint/shared/ClockConfiguration.java +15 −0
- backend/src/main/java/com/tasteprint/shared/ConflictException.java +8 −0
- backend/src/main/java/com/tasteprint/shared/ForbiddenException.java +8 −0
- backend/src/main/java/com/tasteprint/shared/NotFoundException.java +8 −0
- backend/src/main/java/com/tasteprint/shared/OpenApiConfiguration.java +26 −0
- backend/src/main/java/com/tasteprint/shared/package-info.java +5 −0
- backend/src/main/java/com/tasteprint/social/AccountDeletionListener.java +21 −0
- backend/src/main/java/com/tasteprint/social/ChallengeController.java +65 −0
- backend/src/main/java/com/tasteprint/social/ChallengeParticipant.java +44 −0
- backend/src/main/java/com/tasteprint/social/ChallengeParticipantRepository.java +18 −0
- backend/src/main/java/com/tasteprint/social/ChallengeParticipantView.java +12 −0
- backend/src/main/java/com/tasteprint/social/ChallengeService.java +198 −0
- backend/src/main/java/com/tasteprint/social/ChallengeStatus.java +7 −0
- backend/src/main/java/com/tasteprint/social/ChallengeView.java +23 −0
- backend/src/main/java/com/tasteprint/social/CreateChallengeRequest.java +27 −0
- backend/src/main/java/com/tasteprint/social/JoinChallengeRequest.java +9 −0
- backend/src/main/java/com/tasteprint/social/PublicTasteSnapshot.java +15 −0
- backend/src/main/java/com/tasteprint/social/PublicTasteprintView.java +6 −0
- backend/src/main/java/com/tasteprint/social/PublicTastingView.java +18 −0
- backend/src/main/java/com/tasteprint/social/SharingController.java +30 −0
- backend/src/main/java/com/tasteprint/social/SharingService.java +124 −0
- backend/src/main/java/com/tasteprint/social/TasteChallenge.java +82 −0
- backend/src/main/java/com/tasteprint/social/TasteChallengeRepository.java +20 −0
- backend/src/main/java/com/tasteprint/social/TasteComparison.java +18 −0
- backend/src/main/java/com/tasteprint/social/package-info.java +5 −0
- backend/src/main/java/com/tasteprint/tasting/SaveTastingRequest.java +32 −0
- backend/src/main/java/com/tasteprint/tasting/Tasting.java +140 −0
- backend/src/main/java/com/tasteprint/tasting/TastingController.java +58 −0
- backend/src/main/java/com/tasteprint/tasting/TastingPage.java +13 −0
- backend/src/main/java/com/tasteprint/tasting/TastingRecorded.java +7 −0
- backend/src/main/java/com/tasteprint/tasting/TastingRepository.java +52 −0
- backend/src/main/java/com/tasteprint/tasting/TastingService.java +159 −0
- backend/src/main/java/com/tasteprint/tasting/TastingView.java +23 −0
- backend/src/main/java/com/tasteprint/tasting/package-info.java +5 −0
- backend/src/main/resources/application.yml +44 −0
- backend/src/main/resources/db/migration/V1__create_schema.sql +127 −0
- backend/src/main/resources/db/migration/V2__seed_culinary_catalog.sql +98 −0
- backend/src/main/resources/db/migration/V3__track_media_ownership.sql +11 −0
- backend/src/test/java/com/tasteprint/ApiIntegrationTest.java +615 −0
- backend/src/test/java/com/tasteprint/ApplicationContextTest.java +12 −0
- backend/src/test/java/com/tasteprint/ModularityTest.java +12 −0
- backend/src/test/java/com/tasteprint/PostgresCompatibilityTest.java +65 −0
- backend/src/test/resources/application.yml +14 −0
- compose.yml +59 −0
- docs/ARCHITECTURE.md +113 −0
- frontend/.dockerignore +5 −0
- frontend/Dockerfile +12 −0
- frontend/eslint.config.js +25 −0
- frontend/index.html +14 −0
- frontend/nginx-proxy.conf +8 −0
- frontend/nginx.conf +52 −0
- frontend/package-lock.json +4003 −0
- frontend/package.json +47 −0
- frontend/src/App.tsx +47 −0
- frontend/src/components/AppShell.tsx +81 −0
- frontend/src/components/ChallengeFormDialog.tsx +95 −0
- frontend/src/components/DishCard.tsx +39 −0
- frontend/src/components/Logo.tsx +15 −0
- frontend/src/components/ProgressRing.tsx +25 −0
- frontend/src/components/ProtectedRoute.test.tsx +72 −0
- frontend/src/components/ProtectedRoute.tsx +16 −0
- frontend/src/components/QuickTaste.tsx +44 −0
- frontend/src/components/TasteModal.tsx +237 −0
- frontend/src/components/TripFormDialog.tsx +98 −0
- frontend/src/components/Ui.tsx +63 −0
- frontend/src/components/WorldMap.tsx +95 −0
- frontend/src/index.css +2332 −0
- frontend/src/lib/api.test.ts +77 −0
- frontend/src/lib/api.ts +63 −0
- frontend/src/lib/auth.tsx +105 −0
- frontend/src/lib/format.test.ts +22 −0
- frontend/src/lib/format.ts +22 −0
- frontend/src/lib/useModalDialog.test.tsx +49 −0
- frontend/src/lib/useModalDialog.ts +48 −0
- frontend/src/main.tsx +30 −0
- frontend/src/pages/AuthPage.tsx +121 −0
- frontend/src/pages/ChallengeDetailsPage.tsx +107 −0
- frontend/src/pages/ChallengesPage.tsx +94 −0
- frontend/src/pages/ComparePage.tsx +65 −0
- frontend/src/pages/DashboardPage.tsx +174 −0
- frontend/src/pages/DestinationPage.tsx +73 −0
- frontend/src/pages/ExplorePage.tsx +91 −0
- frontend/src/pages/JoinChallengePage.tsx +23 −0
- frontend/src/pages/LandingPage.tsx +100 −0
- frontend/src/pages/NotFoundPage.tsx +11 −0
- frontend/src/pages/ProfilePage.tsx +155 −0
- frontend/src/pages/PublicTasteprintPage.tsx +81 −0
- frontend/src/pages/TastingsPage.tsx +102 −0
- frontend/src/pages/TripDetailsPage.tsx +86 −0
- frontend/src/pages/TripsPage.tsx +67 −0
- frontend/src/test/setup.ts +1 −0
- frontend/src/types.ts +210 −0
- frontend/src/vite-env.d.ts +11 −0
- frontend/tsconfig.json +21 −0
- frontend/vite.config.ts +18 −0
added .editorconfig +15 −0
| @@ -0,0 +1,15 @@ | ||
| 1 | +root = true | |
| 2 | + | |
| 3 | +[*] | |
| 4 | +charset = utf-8 | |
| 5 | +end_of_line = lf | |
| 6 | +insert_final_newline = true | |
| 7 | +indent_style = space | |
| 8 | +indent_size = 2 | |
| 9 | +trim_trailing_whitespace = true | |
| 10 | + | |
| 11 | +[*.java] | |
| 12 | +indent_size = 4 | |
| 13 | + | |
| 14 | +[*.md] | |
| 15 | +trim_trailing_whitespace = false |
added .env.example +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +POSTGRES_DB=tasteprint | |
| 2 | +POSTGRES_USER=tasteprint | |
| 3 | +POSTGRES_PASSWORD=change-me | |
| 4 | +APP_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 | |
| 5 | +DEMO_DATA_ENABLED=true |
added .gitattributes +3 −0
| @@ -0,0 +1,3 @@ | ||
| 1 | +* text=auto eol=lf | |
| 2 | +*.bat text eol=crlf | |
| 3 | +*.cmd text eol=crlf |
added .github/workflows/ci.yml +45 −0
| @@ -0,0 +1,45 @@ | ||
| 1 | +name: CI | |
| 2 | + | |
| 3 | +on: | |
| 4 | + push: | |
| 5 | + branches: [main] | |
| 6 | + pull_request: | |
| 7 | + | |
| 8 | +permissions: | |
| 9 | + contents: read | |
| 10 | + | |
| 11 | +jobs: | |
| 12 | + backend: | |
| 13 | + runs-on: ubuntu-latest | |
| 14 | + steps: | |
| 15 | + - uses: actions/checkout@v4 | |
| 16 | + - uses: actions/setup-java@v4 | |
| 17 | + with: | |
| 18 | + distribution: temurin | |
| 19 | + java-version: "21" | |
| 20 | + cache: maven | |
| 21 | + - name: Test backend | |
| 22 | + working-directory: backend | |
| 23 | + run: ./mvnw verify | |
| 24 | + | |
| 25 | + frontend: | |
| 26 | + runs-on: ubuntu-latest | |
| 27 | + steps: | |
| 28 | + - uses: actions/checkout@v4 | |
| 29 | + - uses: actions/setup-node@v4 | |
| 30 | + with: | |
| 31 | + node-version: "24" | |
| 32 | + cache: npm | |
| 33 | + cache-dependency-path: frontend/package-lock.json | |
| 34 | + - name: Install frontend dependencies | |
| 35 | + working-directory: frontend | |
| 36 | + run: npm ci | |
| 37 | + - name: Lint frontend | |
| 38 | + working-directory: frontend | |
| 39 | + run: npm run lint | |
| 40 | + - name: Test frontend | |
| 41 | + working-directory: frontend | |
| 42 | + run: npm test | |
| 43 | + - name: Build frontend | |
| 44 | + working-directory: frontend | |
| 45 | + run: npm run build |
added .gitignore +15 −0
| @@ -0,0 +1,15 @@ | ||
| 1 | +.idea/ | |
| 2 | +.vscode/ | |
| 3 | +*.iml | |
| 4 | +.DS_Store | |
| 5 | +.env | |
| 6 | + | |
| 7 | +backend/target/ | |
| 8 | +backend/data/ | |
| 9 | +backend/uploads/ | |
| 10 | + | |
| 11 | +frontend/node_modules/ | |
| 12 | +frontend/dist/ | |
| 13 | +frontend/coverage/ | |
| 14 | + | |
| 15 | +*.log |
added README.md +138 −0
| @@ -0,0 +1,138 @@ | ||
| 1 | +# Tasteprint | |
| 2 | + | |
| 3 | +Tasteprint is a travel food passport. Users record dishes they have tried, see which parts of a food culture they have covered, receive a five-bite mission for an upcoming trip, and compare or combine their map with friends. | |
| 4 | + | |
| 5 | +The product measures cultural breadth instead of restaurant visits. Each destination starts with six entry points: staple, street food, breakfast, sweet, drink, and signature dish. Importance weights keep a famous or defining dish more meaningful than a secondary discovery. | |
| 6 | + | |
| 7 | +## What is included | |
| 8 | + | |
| 9 | +- Account registration, login, opaque bearer sessions, logout, editable profiles, and verified account deletion | |
| 10 | +- A curated atlas of 12 destinations and 72 dishes | |
| 11 | +- Tasting journal with ratings, notes, dates, places, coordinates, and validated photo uploads | |
| 12 | +- Weighted world and destination coverage | |
| 13 | +- Important missing dishes based on the user's current map and next trip | |
| 14 | +- Trips with a persisted five-bite mission and date-aware completion | |
| 15 | +- Shared destination challenges with invite codes and group progress | |
| 16 | +- Public Tasteprint pages with privacy controls | |
| 17 | +- Two-person taste map comparison and a suggested shared bite | |
| 18 | +- Responsive desktop and mobile interfaces | |
| 19 | +- OpenAPI documentation, health probes, Docker deployment, and CI | |
| 20 | + | |
| 21 | +## Architecture | |
| 22 | + | |
| 23 | +```text | |
| 24 | +Browser | |
| 25 | + | | |
| 26 | + v | |
| 27 | +Nginx, React single-page application | |
| 28 | + | | |
| 29 | + +---- /api and /uploads | |
| 30 | + | | |
| 31 | + v | |
| 32 | + Spring Boot modular monolith | |
| 33 | + | | | | |
| 34 | + v v v | |
| 35 | + PostgreSQL file media health and OpenAPI | |
| 36 | +``` | |
| 37 | + | |
| 38 | +The backend is a modular monolith. Account, catalog, tasting, journey, progress, media, social, and demo data are separate Java packages with verified Spring Modulith boundaries. This keeps deployment and transactions simple while preserving service boundaries that can be extracted later if scale requires it. | |
| 39 | + | |
| 40 | +See [Architecture](docs/ARCHITECTURE.md) for module ownership, request flows, security choices, and the reasons for not starting with microservices. | |
| 41 | + | |
| 42 | +## Stack | |
| 43 | + | |
| 44 | +| Area | Technology | | |
| 45 | +| --- | --- | | |
| 46 | +| Backend | Java 21, Spring Boot 3.5, Spring Security, Spring Data JPA | | |
| 47 | +| Architecture | Spring Modulith | | |
| 48 | +| Database | PostgreSQL 17, Flyway migrations, H2 for fast integration tests | | |
| 49 | +| Frontend | React 19, TypeScript 5.9, Vite, TanStack Query, React Router | | |
| 50 | +| Map | D3 Geo, TopoJSON, Natural Earth data through world-atlas | | |
| 51 | +| Testing | JUnit 5, MockMvc, Testcontainers, Vitest, Testing Library | | |
| 52 | +| Delivery | Docker Compose, Nginx, GitHub Actions | | |
| 53 | + | |
| 54 | +## Run with Docker | |
| 55 | + | |
| 56 | +Requirements: Docker Desktop or Docker Engine with Compose. | |
| 57 | + | |
| 58 | +```bash | |
| 59 | +cp .env.example .env | |
| 60 | +docker compose up --build | |
| 61 | +``` | |
| 62 | + | |
| 63 | +Open: | |
| 64 | + | |
| 65 | +- Application: `http://localhost:3000` | |
| 66 | +- API documentation: `http://localhost:8080/docs` | |
| 67 | +- Backend readiness: `http://localhost:8080/actuator/health/readiness` | |
| 68 | + | |
| 69 | +The local demo account is: | |
| 70 | + | |
| 71 | +```text | |
| 72 | +Email: demo@tasteprint.app | |
| 73 | +Password: tasteprint | |
| 74 | +``` | |
| 75 | + | |
| 76 | +Set `DEMO_DATA_ENABLED=false` before a public deployment. Replace the sample database password in `.env` with a generated secret. | |
| 77 | + | |
| 78 | +## Run for development | |
| 79 | + | |
| 80 | +Start the backend. No local database is required because development defaults to a file-backed H2 database in PostgreSQL compatibility mode. | |
| 81 | + | |
| 82 | +```powershell | |
| 83 | +cd backend | |
| 84 | +.\mvnw.cmd spring-boot:run | |
| 85 | +``` | |
| 86 | + | |
| 87 | +In another terminal: | |
| 88 | + | |
| 89 | +```powershell | |
| 90 | +cd frontend | |
| 91 | +npm install | |
| 92 | +npm run dev | |
| 93 | +``` | |
| 94 | + | |
| 95 | +Vite serves the interface at `http://localhost:5173` and proxies API and upload requests to port 8080. | |
| 96 | + | |
| 97 | +## Tests | |
| 98 | + | |
| 99 | +Backend tests cover authentication, validation, privacy, ownership, tasting CRUD, paging, trip missions, shared challenge progress, media validation, CORS, module boundaries, and application startup. | |
| 100 | + | |
| 101 | +```powershell | |
| 102 | +cd backend | |
| 103 | +.\mvnw.cmd verify | |
| 104 | +``` | |
| 105 | + | |
| 106 | +The PostgreSQL compatibility test runs automatically when Docker is available and skips cleanly when it is not. | |
| 107 | + | |
| 108 | +Frontend tests cover the API client, expired sessions, protected routing, date behavior, and formatting. The production build also performs strict TypeScript checking. | |
| 109 | + | |
| 110 | +```powershell | |
| 111 | +cd frontend | |
| 112 | +npm test | |
| 113 | +npm run lint | |
| 114 | +npm run build | |
| 115 | +``` | |
| 116 | + | |
| 117 | +## Configuration | |
| 118 | + | |
| 119 | +| Variable | Purpose | Local default | | |
| 120 | +| --- | --- | --- | | |
| 121 | +| `DB_URL` | JDBC database URL | File-backed H2 | | |
| 122 | +| `DB_USER` | Database user | `sa` | | |
| 123 | +| `DB_PASSWORD` | Database password | Empty | | |
| 124 | +| `APP_ALLOWED_ORIGINS` | Comma-separated browser origins | Local frontend ports | | |
| 125 | +| `MEDIA_DIRECTORY` | Photo storage path | `./uploads` | | |
| 126 | +| `DEMO_DATA_ENABLED` | Seed demo accounts and activity | `true` | | |
| 127 | +| `PORT` | Backend HTTP port | `8080` | | |
| 128 | + | |
| 129 | +## Product rules | |
| 130 | + | |
| 131 | +- A dish counts toward a user's global Tasteprint wherever it was eaten. | |
| 132 | +- A dish counts toward a trip only when the tasting country and date match that trip. | |
| 133 | +- A challenge combines member dishes logged within its date range. This allows both travel groups and local tasting clubs. | |
| 134 | +- Coverage is weighted by cultural importance and means breadth, not expertise. | |
| 135 | +- Public pages are private by default and never expose email addresses, exact coordinates, restaurant details, or internal timestamps. | |
| 136 | +- Uploaded files are limited to 6 MB, checked by MIME type and file signature, bound to their owner, and removed with their last tasting or account. | |
| 137 | + | |
| 138 | +The catalog is intentionally stored as reviewed migration data, not copied blindly from a third-party popularity feed. Adding a destination means adding six balanced cultural entry points with an explanation of why each matters. |
added backend/.dockerignore +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +target | |
| 2 | +data | |
| 3 | +uploads | |
| 4 | +.idea | |
| 5 | +*.iml |
added backend/.mvn/wrapper/maven-wrapper.properties +3 −0
| @@ -0,0 +1,3 @@ | ||
| 1 | +wrapperVersion=3.3.4 | |
| 2 | +distributionType=only-script | |
| 3 | +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip |
added backend/Dockerfile +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +FROM eclipse-temurin:21-jdk-alpine AS build | |
| 2 | +WORKDIR /workspace | |
| 3 | +COPY .mvn .mvn | |
| 4 | +COPY mvnw pom.xml ./ | |
| 5 | +RUN sed -i 's/\r$//' mvnw && chmod +x mvnw && ./mvnw -q -DskipTests dependency:go-offline | |
| 6 | +COPY src src | |
| 7 | +RUN ./mvnw -q -DskipTests package | |
| 8 | + | |
| 9 | +FROM eclipse-temurin:21-jre-alpine | |
| 10 | +RUN addgroup -S tasteprint && adduser -S tasteprint -G tasteprint | |
| 11 | +WORKDIR /app | |
| 12 | +RUN mkdir -p /app/uploads && chown -R tasteprint:tasteprint /app | |
| 13 | +COPY --from=build --chown=tasteprint:tasteprint /workspace/target/tasteprint-backend-0.1.0-SNAPSHOT.jar /app/app.jar | |
| 14 | +USER tasteprint | |
| 15 | +EXPOSE 8080 | |
| 16 | +ENTRYPOINT ["java", "-jar", "/app/app.jar"] |
added backend/mvnw +295 −0
| @@ -0,0 +1,295 @@ | ||
| 1 | +#!/bin/sh | |
| 2 | +# ---------------------------------------------------------------------------- | |
| 3 | +# Licensed to the Apache Software Foundation (ASF) under one | |
| 4 | +# or more contributor license agreements. See the NOTICE file | |
| 5 | +# distributed with this work for additional information | |
| 6 | +# regarding copyright ownership. The ASF licenses this file | |
| 7 | +# to you under the Apache License, Version 2.0 (the | |
| 8 | +# "License"); you may not use this file except in compliance | |
| 9 | +# with the License. You may obtain a copy of the License at | |
| 10 | +# | |
| 11 | +# http://www.apache.org/licenses/LICENSE-2.0 | |
| 12 | +# | |
| 13 | +# Unless required by applicable law or agreed to in writing, | |
| 14 | +# software distributed under the License is distributed on an | |
| 15 | +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| 16 | +# KIND, either express or implied. See the License for the | |
| 17 | +# specific language governing permissions and limitations | |
| 18 | +# under the License. | |
| 19 | +# ---------------------------------------------------------------------------- | |
| 20 | + | |
| 21 | +# ---------------------------------------------------------------------------- | |
| 22 | +# Apache Maven Wrapper startup batch script, version 3.3.4 | |
| 23 | +# | |
| 24 | +# Optional ENV vars | |
| 25 | +# ----------------- | |
| 26 | +# JAVA_HOME - location of a JDK home dir, required when download maven via java source | |
| 27 | +# MVNW_REPOURL - repo url base for downloading maven distribution | |
| 28 | +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven | |
| 29 | +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output | |
| 30 | +# ---------------------------------------------------------------------------- | |
| 31 | + | |
| 32 | +set -euf | |
| 33 | +[ "${MVNW_VERBOSE-}" != debug ] || set -x | |
| 34 | + | |
| 35 | +# OS specific support. | |
| 36 | +native_path() { printf %s\\n "$1"; } | |
| 37 | +case "$(uname)" in | |
| 38 | +CYGWIN* | MINGW*) | |
| 39 | + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" | |
| 40 | + native_path() { cygpath --path --windows "$1"; } | |
| 41 | + ;; | |
| 42 | +esac | |
| 43 | + | |
| 44 | +# set JAVACMD and JAVACCMD | |
| 45 | +set_java_home() { | |
| 46 | + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched | |
| 47 | + if [ -n "${JAVA_HOME-}" ]; then | |
| 48 | + if [ -x "$JAVA_HOME/jre/sh/java" ]; then | |
| 49 | + # IBM's JDK on AIX uses strange locations for the executables | |
| 50 | + JAVACMD="$JAVA_HOME/jre/sh/java" | |
| 51 | + JAVACCMD="$JAVA_HOME/jre/sh/javac" | |
| 52 | + else | |
| 53 | + JAVACMD="$JAVA_HOME/bin/java" | |
| 54 | + JAVACCMD="$JAVA_HOME/bin/javac" | |
| 55 | + | |
| 56 | + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then | |
| 57 | + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 | |
| 58 | + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 | |
| 59 | + return 1 | |
| 60 | + fi | |
| 61 | + fi | |
| 62 | + else | |
| 63 | + JAVACMD="$( | |
| 64 | + 'set' +e | |
| 65 | + 'unset' -f command 2>/dev/null | |
| 66 | + 'command' -v java | |
| 67 | + )" || : | |
| 68 | + JAVACCMD="$( | |
| 69 | + 'set' +e | |
| 70 | + 'unset' -f command 2>/dev/null | |
| 71 | + 'command' -v javac | |
| 72 | + )" || : | |
| 73 | + | |
| 74 | + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then | |
| 75 | + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 | |
| 76 | + return 1 | |
| 77 | + fi | |
| 78 | + fi | |
| 79 | +} | |
| 80 | + | |
| 81 | +# hash string like Java String::hashCode | |
| 82 | +hash_string() { | |
| 83 | + str="${1:-}" h=0 | |
| 84 | + while [ -n "$str" ]; do | |
| 85 | + char="${str%"${str#?}"}" | |
| 86 | + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) | |
| 87 | + str="${str#?}" | |
| 88 | + done | |
| 89 | + printf %x\\n $h | |
| 90 | +} | |
| 91 | + | |
| 92 | +verbose() { :; } | |
| 93 | +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } | |
| 94 | + | |
| 95 | +die() { | |
| 96 | + printf %s\\n "$1" >&2 | |
| 97 | + exit 1 | |
| 98 | +} | |
| 99 | + | |
| 100 | +trim() { | |
| 101 | + # MWRAPPER-139: | |
| 102 | + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. | |
| 103 | + # Needed for removing poorly interpreted newline sequences when running in more | |
| 104 | + # exotic environments such as mingw bash on Windows. | |
| 105 | + printf "%s" "${1}" | tr -d '[:space:]' | |
| 106 | +} | |
| 107 | + | |
| 108 | +scriptDir="$(dirname "$0")" | |
| 109 | +scriptName="$(basename "$0")" | |
| 110 | + | |
| 111 | +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties | |
| 112 | +while IFS="=" read -r key value; do | |
| 113 | + case "${key-}" in | |
| 114 | + distributionUrl) distributionUrl=$(trim "${value-}") ;; | |
| 115 | + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; | |
| 116 | + esac | |
| 117 | +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" | |
| 118 | +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" | |
| 119 | + | |
| 120 | +case "${distributionUrl##*/}" in | |
| 121 | +maven-mvnd-*bin.*) | |
| 122 | + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ | |
| 123 | + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in | |
| 124 | + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; | |
| 125 | + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; | |
| 126 | + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; | |
| 127 | + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; | |
| 128 | + *) | |
| 129 | + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 | |
| 130 | + distributionPlatform=linux-amd64 | |
| 131 | + ;; | |
| 132 | + esac | |
| 133 | + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" | |
| 134 | + ;; | |
| 135 | +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; | |
| 136 | +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; | |
| 137 | +esac | |
| 138 | + | |
| 139 | +# apply MVNW_REPOURL and calculate MAVEN_HOME | |
| 140 | +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash> | |
| 141 | +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" | |
| 142 | +distributionUrlName="${distributionUrl##*/}" | |
| 143 | +distributionUrlNameMain="${distributionUrlName%.*}" | |
| 144 | +distributionUrlNameMain="${distributionUrlNameMain%-bin}" | |
| 145 | +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" | |
| 146 | +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" | |
| 147 | + | |
| 148 | +exec_maven() { | |
| 149 | + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : | |
| 150 | + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" | |
| 151 | +} | |
| 152 | + | |
| 153 | +if [ -d "$MAVEN_HOME" ]; then | |
| 154 | + verbose "found existing MAVEN_HOME at $MAVEN_HOME" | |
| 155 | + exec_maven "$@" | |
| 156 | +fi | |
| 157 | + | |
| 158 | +case "${distributionUrl-}" in | |
| 159 | +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; | |
| 160 | +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; | |
| 161 | +esac | |
| 162 | + | |
| 163 | +# prepare tmp dir | |
| 164 | +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then | |
| 165 | + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } | |
| 166 | + trap clean HUP INT TERM EXIT | |
| 167 | +else | |
| 168 | + die "cannot create temp dir" | |
| 169 | +fi | |
| 170 | + | |
| 171 | +mkdir -p -- "${MAVEN_HOME%/*}" | |
| 172 | + | |
| 173 | +# Download and Install Apache Maven | |
| 174 | +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." | |
| 175 | +verbose "Downloading from: $distributionUrl" | |
| 176 | +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" | |
| 177 | + | |
| 178 | +# select .zip or .tar.gz | |
| 179 | +if ! command -v unzip >/dev/null; then | |
| 180 | + distributionUrl="${distributionUrl%.zip}.tar.gz" | |
| 181 | + distributionUrlName="${distributionUrl##*/}" | |
| 182 | +fi | |
| 183 | + | |
| 184 | +# verbose opt | |
| 185 | +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' | |
| 186 | +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v | |
| 187 | + | |
| 188 | +# normalize http auth | |
| 189 | +case "${MVNW_PASSWORD:+has-password}" in | |
| 190 | +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; | |
| 191 | +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; | |
| 192 | +esac | |
| 193 | + | |
| 194 | +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then | |
| 195 | + verbose "Found wget ... using wget" | |
| 196 | + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" | |
| 197 | +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then | |
| 198 | + verbose "Found curl ... using curl" | |
| 199 | + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" | |
| 200 | +elif set_java_home; then | |
| 201 | + verbose "Falling back to use Java to download" | |
| 202 | + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" | |
| 203 | + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" | |
| 204 | + cat >"$javaSource" <<-END | |
| 205 | + public class Downloader extends java.net.Authenticator | |
| 206 | + { | |
| 207 | + protected java.net.PasswordAuthentication getPasswordAuthentication() | |
| 208 | + { | |
| 209 | + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); | |
| 210 | + } | |
| 211 | + public static void main( String[] args ) throws Exception | |
| 212 | + { | |
| 213 | + setDefault( new Downloader() ); | |
| 214 | + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); | |
| 215 | + } | |
| 216 | + } | |
| 217 | + END | |
| 218 | + # For Cygwin/MinGW, switch paths to Windows format before running javac and java | |
| 219 | + verbose " - Compiling Downloader.java ..." | |
| 220 | + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" | |
| 221 | + verbose " - Running Downloader.java ..." | |
| 222 | + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" | |
| 223 | +fi | |
| 224 | + | |
| 225 | +# If specified, validate the SHA-256 sum of the Maven distribution zip file | |
| 226 | +if [ -n "${distributionSha256Sum-}" ]; then | |
| 227 | + distributionSha256Result=false | |
| 228 | + if [ "$MVN_CMD" = mvnd.sh ]; then | |
| 229 | + echo "Checksum validation is not supported for maven-mvnd." >&2 | |
| 230 | + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 | |
| 231 | + exit 1 | |
| 232 | + elif command -v sha256sum >/dev/null; then | |
| 233 | + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then | |
| 234 | + distributionSha256Result=true | |
| 235 | + fi | |
| 236 | + elif command -v shasum >/dev/null; then | |
| 237 | + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then | |
| 238 | + distributionSha256Result=true | |
| 239 | + fi | |
| 240 | + else | |
| 241 | + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 | |
| 242 | + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 | |
| 243 | + exit 1 | |
| 244 | + fi | |
| 245 | + if [ $distributionSha256Result = false ]; then | |
| 246 | + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 | |
| 247 | + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 | |
| 248 | + exit 1 | |
| 249 | + fi | |
| 250 | +fi | |
| 251 | + | |
| 252 | +# unzip and move | |
| 253 | +if command -v unzip >/dev/null; then | |
| 254 | + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" | |
| 255 | +else | |
| 256 | + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" | |
| 257 | +fi | |
| 258 | + | |
| 259 | +# Find the actual extracted directory name (handles snapshots where filename != directory name) | |
| 260 | +actualDistributionDir="" | |
| 261 | + | |
| 262 | +# First try the expected directory name (for regular distributions) | |
| 263 | +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then | |
| 264 | + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then | |
| 265 | + actualDistributionDir="$distributionUrlNameMain" | |
| 266 | + fi | |
| 267 | +fi | |
| 268 | + | |
| 269 | +# If not found, search for any directory with the Maven executable (for snapshots) | |
| 270 | +if [ -z "$actualDistributionDir" ]; then | |
| 271 | + # enable globbing to iterate over items | |
| 272 | + set +f | |
| 273 | + for dir in "$TMP_DOWNLOAD_DIR"/*; do | |
| 274 | + if [ -d "$dir" ]; then | |
| 275 | + if [ -f "$dir/bin/$MVN_CMD" ]; then | |
| 276 | + actualDistributionDir="$(basename "$dir")" | |
| 277 | + break | |
| 278 | + fi | |
| 279 | + fi | |
| 280 | + done | |
| 281 | + set -f | |
| 282 | +fi | |
| 283 | + | |
| 284 | +if [ -z "$actualDistributionDir" ]; then | |
| 285 | + verbose "Contents of $TMP_DOWNLOAD_DIR:" | |
| 286 | + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" | |
| 287 | + die "Could not find Maven distribution directory in extracted archive" | |
| 288 | +fi | |
| 289 | + | |
| 290 | +verbose "Found extracted Maven distribution directory: $actualDistributionDir" | |
| 291 | +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" | |
| 292 | +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" | |
| 293 | + | |
| 294 | +clean || : | |
| 295 | +exec_maven "$@" |
added backend/mvnw.cmd +189 −0
| @@ -0,0 +1,189 @@ | ||
| 1 | +<# : batch portion | |
| 2 | +@REM ---------------------------------------------------------------------------- | |
| 3 | +@REM Licensed to the Apache Software Foundation (ASF) under one | |
| 4 | +@REM or more contributor license agreements. See the NOTICE file | |
| 5 | +@REM distributed with this work for additional information | |
| 6 | +@REM regarding copyright ownership. The ASF licenses this file | |
| 7 | +@REM to you under the Apache License, Version 2.0 (the | |
| 8 | +@REM "License"); you may not use this file except in compliance | |
| 9 | +@REM with the License. You may obtain a copy of the License at | |
| 10 | +@REM | |
| 11 | +@REM http://www.apache.org/licenses/LICENSE-2.0 | |
| 12 | +@REM | |
| 13 | +@REM Unless required by applicable law or agreed to in writing, | |
| 14 | +@REM software distributed under the License is distributed on an | |
| 15 | +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| 16 | +@REM KIND, either express or implied. See the License for the | |
| 17 | +@REM specific language governing permissions and limitations | |
| 18 | +@REM under the License. | |
| 19 | +@REM ---------------------------------------------------------------------------- | |
| 20 | + | |
| 21 | +@REM ---------------------------------------------------------------------------- | |
| 22 | +@REM Apache Maven Wrapper startup batch script, version 3.3.4 | |
| 23 | +@REM | |
| 24 | +@REM Optional ENV vars | |
| 25 | +@REM MVNW_REPOURL - repo url base for downloading maven distribution | |
| 26 | +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven | |
| 27 | +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output | |
| 28 | +@REM ---------------------------------------------------------------------------- | |
| 29 | + | |
| 30 | +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) | |
| 31 | +@SET __MVNW_CMD__= | |
| 32 | +@SET __MVNW_ERROR__= | |
| 33 | +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% | |
| 34 | +@SET PSModulePath= | |
| 35 | +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( | |
| 36 | + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) | |
| 37 | +) | |
| 38 | +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% | |
| 39 | +@SET __MVNW_PSMODULEP_SAVE= | |
| 40 | +@SET __MVNW_ARG0_NAME__= | |
| 41 | +@SET MVNW_USERNAME= | |
| 42 | +@SET MVNW_PASSWORD= | |
| 43 | +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) | |
| 44 | +@echo Cannot start maven from wrapper >&2 && exit /b 1 | |
| 45 | +@GOTO :EOF | |
| 46 | +: end batch / begin powershell #> | |
| 47 | + | |
| 48 | +$ErrorActionPreference = "Stop" | |
| 49 | +if ($env:MVNW_VERBOSE -eq "true") { | |
| 50 | + $VerbosePreference = "Continue" | |
| 51 | +} | |
| 52 | + | |
| 53 | +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties | |
| 54 | +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl | |
| 55 | +if (!$distributionUrl) { | |
| 56 | + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" | |
| 57 | +} | |
| 58 | + | |
| 59 | +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { | |
| 60 | + "maven-mvnd-*" { | |
| 61 | + $USE_MVND = $true | |
| 62 | + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" | |
| 63 | + $MVN_CMD = "mvnd.cmd" | |
| 64 | + break | |
| 65 | + } | |
| 66 | + default { | |
| 67 | + $USE_MVND = $false | |
| 68 | + $MVN_CMD = $script -replace '^mvnw','mvn' | |
| 69 | + break | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +# apply MVNW_REPOURL and calculate MAVEN_HOME | |
| 74 | +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash> | |
| 75 | +if ($env:MVNW_REPOURL) { | |
| 76 | + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } | |
| 77 | + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" | |
| 78 | +} | |
| 79 | +$distributionUrlName = $distributionUrl -replace '^.*/','' | |
| 80 | +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' | |
| 81 | + | |
| 82 | +$MAVEN_M2_PATH = "$HOME/.m2" | |
| 83 | +if ($env:MAVEN_USER_HOME) { | |
| 84 | + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" | |
| 85 | +} | |
| 86 | + | |
| 87 | +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { | |
| 88 | + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null | |
| 89 | +} | |
| 90 | + | |
| 91 | +$MAVEN_WRAPPER_DISTS = $null | |
| 92 | +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { | |
| 93 | + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" | |
| 94 | +} else { | |
| 95 | + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" | |
| 96 | +} | |
| 97 | + | |
| 98 | +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" | |
| 99 | +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' | |
| 100 | +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" | |
| 101 | + | |
| 102 | +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { | |
| 103 | + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" | |
| 104 | + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" | |
| 105 | + exit $? | |
| 106 | +} | |
| 107 | + | |
| 108 | +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { | |
| 109 | + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" | |
| 110 | +} | |
| 111 | + | |
| 112 | +# prepare tmp dir | |
| 113 | +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile | |
| 114 | +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" | |
| 115 | +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null | |
| 116 | +trap { | |
| 117 | + if ($TMP_DOWNLOAD_DIR.Exists) { | |
| 118 | + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } | |
| 119 | + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } | |
| 120 | + } | |
| 121 | +} | |
| 122 | + | |
| 123 | +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null | |
| 124 | + | |
| 125 | +# Download and Install Apache Maven | |
| 126 | +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." | |
| 127 | +Write-Verbose "Downloading from: $distributionUrl" | |
| 128 | +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" | |
| 129 | + | |
| 130 | +$webclient = New-Object System.Net.WebClient | |
| 131 | +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { | |
| 132 | + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) | |
| 133 | +} | |
| 134 | +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 | |
| 135 | +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null | |
| 136 | + | |
| 137 | +# If specified, validate the SHA-256 sum of the Maven distribution zip file | |
| 138 | +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum | |
| 139 | +if ($distributionSha256Sum) { | |
| 140 | + if ($USE_MVND) { | |
| 141 | + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." | |
| 142 | + } | |
| 143 | + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash | |
| 144 | + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { | |
| 145 | + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." | |
| 146 | + } | |
| 147 | +} | |
| 148 | + | |
| 149 | +# unzip and move | |
| 150 | +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null | |
| 151 | + | |
| 152 | +# Find the actual extracted directory name (handles snapshots where filename != directory name) | |
| 153 | +$actualDistributionDir = "" | |
| 154 | + | |
| 155 | +# First try the expected directory name (for regular distributions) | |
| 156 | +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" | |
| 157 | +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" | |
| 158 | +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { | |
| 159 | + $actualDistributionDir = $distributionUrlNameMain | |
| 160 | +} | |
| 161 | + | |
| 162 | +# If not found, search for any directory with the Maven executable (for snapshots) | |
| 163 | +if (!$actualDistributionDir) { | |
| 164 | + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { | |
| 165 | + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" | |
| 166 | + if (Test-Path -Path $testPath -PathType Leaf) { | |
| 167 | + $actualDistributionDir = $_.Name | |
| 168 | + } | |
| 169 | + } | |
| 170 | +} | |
| 171 | + | |
| 172 | +if (!$actualDistributionDir) { | |
| 173 | + Write-Error "Could not find Maven distribution directory in extracted archive" | |
| 174 | +} | |
| 175 | + | |
| 176 | +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" | |
| 177 | +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null | |
| 178 | +try { | |
| 179 | + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null | |
| 180 | +} catch { | |
| 181 | + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { | |
| 182 | + Write-Error "fail to move MAVEN_HOME" | |
| 183 | + } | |
| 184 | +} finally { | |
| 185 | + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } | |
| 186 | + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } | |
| 187 | +} | |
| 188 | + | |
| 189 | +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" |
added backend/pom.xml +144 −0
| @@ -0,0 +1,144 @@ | ||
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<project xmlns="http://maven.apache.org/POM/4.0.0" | |
| 3 | + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
| 4 | + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> | |
| 5 | + <modelVersion>4.0.0</modelVersion> | |
| 6 | + | |
| 7 | + <parent> | |
| 8 | + <groupId>org.springframework.boot</groupId> | |
| 9 | + <artifactId>spring-boot-starter-parent</artifactId> | |
| 10 | + <version>3.5.16</version> | |
| 11 | + <relativePath/> | |
| 12 | + </parent> | |
| 13 | + | |
| 14 | + <groupId>com.tasteprint</groupId> | |
| 15 | + <artifactId>tasteprint-backend</artifactId> | |
| 16 | + <version>0.1.0-SNAPSHOT</version> | |
| 17 | + <name>tasteprint-backend</name> | |
| 18 | + <description>Travel food passport and culinary coverage API</description> | |
| 19 | + | |
| 20 | + <properties> | |
| 21 | + <java.version>21</java.version> | |
| 22 | + <spring-modulith.version>1.4.12</spring-modulith.version> | |
| 23 | + <springdoc.version>2.8.14</springdoc.version> | |
| 24 | + <testcontainers.version>1.21.4</testcontainers.version> | |
| 25 | + </properties> | |
| 26 | + | |
| 27 | + <dependencyManagement> | |
| 28 | + <dependencies> | |
| 29 | + <dependency> | |
| 30 | + <groupId>org.springframework.modulith</groupId> | |
| 31 | + <artifactId>spring-modulith-bom</artifactId> | |
| 32 | + <version>${spring-modulith.version}</version> | |
| 33 | + <type>pom</type> | |
| 34 | + <scope>import</scope> | |
| 35 | + </dependency> | |
| 36 | + <dependency> | |
| 37 | + <groupId>org.testcontainers</groupId> | |
| 38 | + <artifactId>testcontainers-bom</artifactId> | |
| 39 | + <version>${testcontainers.version}</version> | |
| 40 | + <type>pom</type> | |
| 41 | + <scope>import</scope> | |
| 42 | + </dependency> | |
| 43 | + </dependencies> | |
| 44 | + </dependencyManagement> | |
| 45 | + | |
| 46 | + <dependencies> | |
| 47 | + <dependency> | |
| 48 | + <groupId>org.springframework.boot</groupId> | |
| 49 | + <artifactId>spring-boot-starter-actuator</artifactId> | |
| 50 | + </dependency> | |
| 51 | + <dependency> | |
| 52 | + <groupId>org.springframework.boot</groupId> | |
| 53 | + <artifactId>spring-boot-starter-data-jpa</artifactId> | |
| 54 | + </dependency> | |
| 55 | + <dependency> | |
| 56 | + <groupId>org.springframework.boot</groupId> | |
| 57 | + <artifactId>spring-boot-starter-security</artifactId> | |
| 58 | + </dependency> | |
| 59 | + <dependency> | |
| 60 | + <groupId>org.springframework.boot</groupId> | |
| 61 | + <artifactId>spring-boot-starter-validation</artifactId> | |
| 62 | + </dependency> | |
| 63 | + <dependency> | |
| 64 | + <groupId>org.springframework.boot</groupId> | |
| 65 | + <artifactId>spring-boot-starter-web</artifactId> | |
| 66 | + </dependency> | |
| 67 | + <dependency> | |
| 68 | + <groupId>org.springframework.modulith</groupId> | |
| 69 | + <artifactId>spring-modulith-starter-core</artifactId> | |
| 70 | + </dependency> | |
| 71 | + <dependency> | |
| 72 | + <groupId>org.flywaydb</groupId> | |
| 73 | + <artifactId>flyway-core</artifactId> | |
| 74 | + </dependency> | |
| 75 | + <dependency> | |
| 76 | + <groupId>org.flywaydb</groupId> | |
| 77 | + <artifactId>flyway-database-postgresql</artifactId> | |
| 78 | + </dependency> | |
| 79 | + <dependency> | |
| 80 | + <groupId>org.springdoc</groupId> | |
| 81 | + <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> | |
| 82 | + <version>${springdoc.version}</version> | |
| 83 | + </dependency> | |
| 84 | + | |
| 85 | + <dependency> | |
| 86 | + <groupId>com.h2database</groupId> | |
| 87 | + <artifactId>h2</artifactId> | |
| 88 | + <scope>runtime</scope> | |
| 89 | + </dependency> | |
| 90 | + <dependency> | |
| 91 | + <groupId>org.postgresql</groupId> | |
| 92 | + <artifactId>postgresql</artifactId> | |
| 93 | + <scope>runtime</scope> | |
| 94 | + </dependency> | |
| 95 | + | |
| 96 | + <dependency> | |
| 97 | + <groupId>org.springframework.boot</groupId> | |
| 98 | + <artifactId>spring-boot-starter-test</artifactId> | |
| 99 | + <scope>test</scope> | |
| 100 | + </dependency> | |
| 101 | + <dependency> | |
| 102 | + <groupId>org.springframework.security</groupId> | |
| 103 | + <artifactId>spring-security-test</artifactId> | |
| 104 | + <scope>test</scope> | |
| 105 | + </dependency> | |
| 106 | + <dependency> | |
| 107 | + <groupId>org.springframework.modulith</groupId> | |
| 108 | + <artifactId>spring-modulith-starter-test</artifactId> | |
| 109 | + <scope>test</scope> | |
| 110 | + </dependency> | |
| 111 | + <dependency> | |
| 112 | + <groupId>org.springframework.boot</groupId> | |
| 113 | + <artifactId>spring-boot-testcontainers</artifactId> | |
| 114 | + <scope>test</scope> | |
| 115 | + </dependency> | |
| 116 | + <dependency> | |
| 117 | + <groupId>org.testcontainers</groupId> | |
| 118 | + <artifactId>junit-jupiter</artifactId> | |
| 119 | + <scope>test</scope> | |
| 120 | + </dependency> | |
| 121 | + <dependency> | |
| 122 | + <groupId>org.testcontainers</groupId> | |
| 123 | + <artifactId>postgresql</artifactId> | |
| 124 | + <scope>test</scope> | |
| 125 | + </dependency> | |
| 126 | + </dependencies> | |
| 127 | + | |
| 128 | + <build> | |
| 129 | + <plugins> | |
| 130 | + <plugin> | |
| 131 | + <groupId>org.springframework.boot</groupId> | |
| 132 | + <artifactId>spring-boot-maven-plugin</artifactId> | |
| 133 | + </plugin> | |
| 134 | + <plugin> | |
| 135 | + <groupId>org.apache.maven.plugins</groupId> | |
| 136 | + <artifactId>maven-compiler-plugin</artifactId> | |
| 137 | + <configuration> | |
| 138 | + <parameters>true</parameters> | |
| 139 | + <release>${java.version}</release> | |
| 140 | + </configuration> | |
| 141 | + </plugin> | |
| 142 | + </plugins> | |
| 143 | + </build> | |
| 144 | +</project> |
added backend/src/main/java/com/tasteprint/TasteprintApplication.java +13 −0
| @@ -0,0 +1,13 @@ | ||
| 1 | +package com.tasteprint; | |
| 2 | + | |
| 3 | +import org.springframework.boot.SpringApplication; | |
| 4 | +import org.springframework.boot.autoconfigure.SpringBootApplication; | |
| 5 | +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; | |
| 6 | + | |
| 7 | +@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class) | |
| 8 | +public class TasteprintApplication { | |
| 9 | + | |
| 10 | + public static void main(String[] args) { | |
| 11 | + SpringApplication.run(TasteprintApplication.class, args); | |
| 12 | + } | |
| 13 | +} |
added backend/src/main/java/com/tasteprint/account/Account.java +131 −0
| @@ -0,0 +1,131 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import java.time.Instant; | |
| 4 | +import java.util.Locale; | |
| 5 | +import java.util.UUID; | |
| 6 | + | |
| 7 | +import jakarta.persistence.Column; | |
| 8 | +import jakarta.persistence.Entity; | |
| 9 | +import jakarta.persistence.Id; | |
| 10 | +import jakarta.persistence.Table; | |
| 11 | + | |
| 12 | +@Entity | |
| 13 | +@Table(name = "app_user") | |
| 14 | +class Account { | |
| 15 | + | |
| 16 | + @Id | |
| 17 | + private UUID id; | |
| 18 | + | |
| 19 | + @Column(name = "display_name", nullable = false, length = 80) | |
| 20 | + private String displayName; | |
| 21 | + | |
| 22 | + @Column(nullable = false, length = 254, unique = true) | |
| 23 | + private String email; | |
| 24 | + | |
| 25 | + @Column(name = "password_hash", nullable = false, length = 100) | |
| 26 | + private String passwordHash; | |
| 27 | + | |
| 28 | + @Column(name = "share_slug", nullable = false, length = 100, unique = true) | |
| 29 | + private String shareSlug; | |
| 30 | + | |
| 31 | + @Column(name = "home_city", length = 100) | |
| 32 | + private String homeCity; | |
| 33 | + | |
| 34 | + @Column(name = "home_country_code", length = 2) | |
| 35 | + private String homeCountryCode; | |
| 36 | + | |
| 37 | + @Column(length = 280) | |
| 38 | + private String bio; | |
| 39 | + | |
| 40 | + @Column(name = "avatar_url", length = 500) | |
| 41 | + private String avatarUrl; | |
| 42 | + | |
| 43 | + @Column(name = "profile_public", nullable = false) | |
| 44 | + private boolean profilePublic; | |
| 45 | + | |
| 46 | + @Column(name = "created_at", nullable = false) | |
| 47 | + private Instant createdAt; | |
| 48 | + | |
| 49 | + @Column(name = "updated_at", nullable = false) | |
| 50 | + private Instant updatedAt; | |
| 51 | + | |
| 52 | + protected Account() { | |
| 53 | + } | |
| 54 | + | |
| 55 | + Account(UUID id, String displayName, String email, String passwordHash, String shareSlug, Instant now) { | |
| 56 | + this.id = id; | |
| 57 | + this.displayName = displayName.trim(); | |
| 58 | + this.email = email.trim().toLowerCase(Locale.ROOT); | |
| 59 | + this.passwordHash = passwordHash; | |
| 60 | + this.shareSlug = shareSlug; | |
| 61 | + this.profilePublic = false; | |
| 62 | + this.createdAt = now; | |
| 63 | + this.updatedAt = now; | |
| 64 | + } | |
| 65 | + | |
| 66 | + void updateProfile(String displayName, String homeCity, String homeCountryCode, String bio, | |
| 67 | + String avatarUrl, boolean profilePublic, Instant now) { | |
| 68 | + this.displayName = displayName.trim(); | |
| 69 | + this.homeCity = clean(homeCity); | |
| 70 | + this.homeCountryCode = clean(homeCountryCode) == null | |
| 71 | + ? null | |
| 72 | + : homeCountryCode.trim().toUpperCase(Locale.ROOT); | |
| 73 | + this.bio = clean(bio); | |
| 74 | + this.avatarUrl = clean(avatarUrl); | |
| 75 | + this.profilePublic = profilePublic; | |
| 76 | + this.updatedAt = now; | |
| 77 | + } | |
| 78 | + | |
| 79 | + void makePublic(Instant now) { | |
| 80 | + this.profilePublic = true; | |
| 81 | + this.updatedAt = now; | |
| 82 | + } | |
| 83 | + | |
| 84 | + private String clean(String value) { | |
| 85 | + return value == null || value.isBlank() ? null : value.trim(); | |
| 86 | + } | |
| 87 | + | |
| 88 | + UUID id() { | |
| 89 | + return id; | |
| 90 | + } | |
| 91 | + | |
| 92 | + String displayName() { | |
| 93 | + return displayName; | |
| 94 | + } | |
| 95 | + | |
| 96 | + String email() { | |
| 97 | + return email; | |
| 98 | + } | |
| 99 | + | |
| 100 | + String passwordHash() { | |
| 101 | + return passwordHash; | |
| 102 | + } | |
| 103 | + | |
| 104 | + String shareSlug() { | |
| 105 | + return shareSlug; | |
| 106 | + } | |
| 107 | + | |
| 108 | + String homeCity() { | |
| 109 | + return homeCity; | |
| 110 | + } | |
| 111 | + | |
| 112 | + String homeCountryCode() { | |
| 113 | + return homeCountryCode; | |
| 114 | + } | |
| 115 | + | |
| 116 | + String bio() { | |
| 117 | + return bio; | |
| 118 | + } | |
| 119 | + | |
| 120 | + String avatarUrl() { | |
| 121 | + return avatarUrl; | |
| 122 | + } | |
| 123 | + | |
| 124 | + boolean profilePublic() { | |
| 125 | + return profilePublic; | |
| 126 | + } | |
| 127 | + | |
| 128 | + Instant createdAt() { | |
| 129 | + return createdAt; | |
| 130 | + } | |
| 131 | +} |
added backend/src/main/java/com/tasteprint/account/AccountDeletionRequested.java +6 −0
| @@ -0,0 +1,6 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import java.util.UUID; | |
| 4 | + | |
| 5 | +public record AccountDeletionRequested(UUID accountId) { | |
| 6 | +} |
added backend/src/main/java/com/tasteprint/account/AccountRepository.java +15 −0
| @@ -0,0 +1,15 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import java.util.Optional; | |
| 4 | +import java.util.UUID; | |
| 5 | + | |
| 6 | +import org.springframework.data.jpa.repository.JpaRepository; | |
| 7 | + | |
| 8 | +interface AccountRepository extends JpaRepository<Account, UUID> { | |
| 9 | + | |
| 10 | + Optional<Account> findByEmailIgnoreCase(String email); | |
| 11 | + | |
| 12 | + Optional<Account> findByShareSlug(String shareSlug); | |
| 13 | + | |
| 14 | + boolean existsByShareSlug(String shareSlug); | |
| 15 | +} |
added backend/src/main/java/com/tasteprint/account/AccountService.java +214 −0
| @@ -0,0 +1,214 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import java.nio.charset.StandardCharsets; | |
| 4 | +import java.security.MessageDigest; | |
| 5 | +import java.security.NoSuchAlgorithmException; | |
| 6 | +import java.security.SecureRandom; | |
| 7 | +import java.time.Clock; | |
| 8 | +import java.time.Duration; | |
| 9 | +import java.time.Instant; | |
| 10 | +import java.util.Base64; | |
| 11 | +import java.util.HexFormat; | |
| 12 | +import java.util.Locale; | |
| 13 | +import java.util.UUID; | |
| 14 | + | |
| 15 | +import org.springframework.security.authentication.BadCredentialsException; | |
| 16 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 17 | +import org.springframework.context.ApplicationEventPublisher; | |
| 18 | +import org.springframework.stereotype.Service; | |
| 19 | +import org.springframework.transaction.annotation.Transactional; | |
| 20 | + | |
| 21 | +import com.tasteprint.shared.ConflictException; | |
| 22 | +import com.tasteprint.shared.ForbiddenException; | |
| 23 | +import com.tasteprint.shared.NotFoundException; | |
| 24 | + | |
| 25 | +@Service | |
| 26 | +public class AccountService { | |
| 27 | + | |
| 28 | + private static final Duration SESSION_LIFETIME = Duration.ofDays(30); | |
| 29 | + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); | |
| 30 | + | |
| 31 | + private final AccountRepository accounts; | |
| 32 | + private final SessionTokenRepository sessions; | |
| 33 | + private final PasswordEncoder passwordEncoder; | |
| 34 | + private final ApplicationEventPublisher events; | |
| 35 | + private final Clock clock; | |
| 36 | + | |
| 37 | + AccountService(AccountRepository accounts, SessionTokenRepository sessions, | |
| 38 | + PasswordEncoder passwordEncoder, ApplicationEventPublisher events, Clock clock) { | |
| 39 | + this.accounts = accounts; | |
| 40 | + this.sessions = sessions; | |
| 41 | + this.passwordEncoder = passwordEncoder; | |
| 42 | + this.events = events; | |
| 43 | + this.clock = clock; | |
| 44 | + } | |
| 45 | + | |
| 46 | + @Transactional | |
| 47 | + public AuthSession register(RegisterRequest request) { | |
| 48 | + String email = request.email().trim().toLowerCase(Locale.ROOT); | |
| 49 | + if (accounts.findByEmailIgnoreCase(email).isPresent()) { | |
| 50 | + throw new ConflictException("An account with that email already exists."); | |
| 51 | + } | |
| 52 | + | |
| 53 | + Instant now = clock.instant(); | |
| 54 | + Account account = new Account( | |
| 55 | + UUID.randomUUID(), | |
| 56 | + request.displayName(), | |
| 57 | + email, | |
| 58 | + passwordEncoder.encode(request.password()), | |
| 59 | + uniqueShareSlug(request.displayName()), | |
| 60 | + now | |
| 61 | + ); | |
| 62 | + accounts.save(account); | |
| 63 | + return newSession(account, now); | |
| 64 | + } | |
| 65 | + | |
| 66 | + @Transactional | |
| 67 | + public AuthSession login(LoginRequest request) { | |
| 68 | + Account account = accounts.findByEmailIgnoreCase(request.email().trim()) | |
| 69 | + .orElseThrow(() -> new BadCredentialsException("Invalid email or password.")); | |
| 70 | + if (!passwordEncoder.matches(request.password(), account.passwordHash())) { | |
| 71 | + throw new BadCredentialsException("Invalid email or password."); | |
| 72 | + } | |
| 73 | + Instant now = clock.instant(); | |
| 74 | + sessions.deleteByExpiresAtBefore(now); | |
| 75 | + return newSession(account, now); | |
| 76 | + } | |
| 77 | + | |
| 78 | + @Transactional | |
| 79 | + public void logout(String rawToken) { | |
| 80 | + if (rawToken != null && !rawToken.isBlank()) { | |
| 81 | + sessions.deleteByTokenHash(hash(rawToken)); | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + @Transactional(readOnly = true) | |
| 86 | + public AuthenticatedUser authenticate(String rawToken) { | |
| 87 | + SessionToken session = sessions.findByTokenHashAndExpiresAtAfter(hash(rawToken), clock.instant()) | |
| 88 | + .orElseThrow(() -> new BadCredentialsException("Session is invalid or expired.")); | |
| 89 | + Account account = requiredAccount(session.userId()); | |
| 90 | + return new AuthenticatedUser(account.id(), account.email(), account.displayName(), account.shareSlug()); | |
| 91 | + } | |
| 92 | + | |
| 93 | + @Transactional(readOnly = true) | |
| 94 | + public AccountView get(UUID accountId) { | |
| 95 | + return view(requiredAccount(accountId)); | |
| 96 | + } | |
| 97 | + | |
| 98 | + @Transactional(readOnly = true) | |
| 99 | + public PublicAccountView getPublic(String shareSlug) { | |
| 100 | + Account account = accounts.findByShareSlug(shareSlug) | |
| 101 | + .orElseThrow(() -> new NotFoundException("Tasteprint profile was not found.")); | |
| 102 | + if (!account.profilePublic()) { | |
| 103 | + throw new NotFoundException("Tasteprint profile was not found."); | |
| 104 | + } | |
| 105 | + return publicView(account); | |
| 106 | + } | |
| 107 | + | |
| 108 | + @Transactional(readOnly = true) | |
| 109 | + public PublicAccountView getPublicById(UUID accountId) { | |
| 110 | + Account account = requiredAccount(accountId); | |
| 111 | + return publicView(account); | |
| 112 | + } | |
| 113 | + | |
| 114 | + @Transactional | |
| 115 | + public AccountView update(UUID accountId, UpdateProfileRequest request) { | |
| 116 | + Account account = requiredAccount(accountId); | |
| 117 | + account.updateProfile( | |
| 118 | + request.displayName(), request.homeCity(), request.homeCountryCode(), request.bio(), | |
| 119 | + request.avatarUrl(), request.profilePublic(), clock.instant() | |
| 120 | + ); | |
| 121 | + return view(account); | |
| 122 | + } | |
| 123 | + | |
| 124 | + @Transactional | |
| 125 | + public void delete(UUID accountId, String password) { | |
| 126 | + Account account = requiredAccount(accountId); | |
| 127 | + if (!passwordEncoder.matches(password, account.passwordHash())) { | |
| 128 | + throw new BadCredentialsException("Invalid password."); | |
| 129 | + } | |
| 130 | + events.publishEvent(new AccountDeletionRequested(accountId)); | |
| 131 | + accounts.delete(account); | |
| 132 | + } | |
| 133 | + | |
| 134 | + @Transactional | |
| 135 | + public UUID ensureDemoAccount(String displayName, String email, String password, boolean publicProfile) { | |
| 136 | + Account account = accounts.findByEmailIgnoreCase(email).orElseGet(() -> { | |
| 137 | + Instant now = clock.instant(); | |
| 138 | + return accounts.save(new Account( | |
| 139 | + UUID.randomUUID(), displayName, email, passwordEncoder.encode(password), | |
| 140 | + uniqueShareSlug(displayName), now | |
| 141 | + )); | |
| 142 | + }); | |
| 143 | + if (publicProfile && !account.profilePublic()) { | |
| 144 | + account.makePublic(clock.instant()); | |
| 145 | + } | |
| 146 | + return account.id(); | |
| 147 | + } | |
| 148 | + | |
| 149 | + @Transactional(readOnly = true) | |
| 150 | + public void requireSameUser(UUID authenticatedUserId, UUID requestedUserId) { | |
| 151 | + if (!authenticatedUserId.equals(requestedUserId)) { | |
| 152 | + throw new ForbiddenException("You cannot access another account's private data."); | |
| 153 | + } | |
| 154 | + } | |
| 155 | + | |
| 156 | + private AuthSession newSession(Account account, Instant now) { | |
| 157 | + byte[] bytes = new byte[32]; | |
| 158 | + SECURE_RANDOM.nextBytes(bytes); | |
| 159 | + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); | |
| 160 | + sessions.save(new SessionToken( | |
| 161 | + UUID.randomUUID(), account.id(), hash(token), now.plus(SESSION_LIFETIME), now | |
| 162 | + )); | |
| 163 | + return new AuthSession(token, view(account)); | |
| 164 | + } | |
| 165 | + | |
| 166 | + private String uniqueShareSlug(String displayName) { | |
| 167 | + String base = displayName.toLowerCase(Locale.ROOT) | |
| 168 | + .replaceAll("[^a-z0-9]+", "-") | |
| 169 | + .replaceAll("(^-|-$)", ""); | |
| 170 | + if (base.isBlank()) { | |
| 171 | + base = "traveller"; | |
| 172 | + } | |
| 173 | + base = base.substring(0, Math.min(base.length(), 70)); | |
| 174 | + | |
| 175 | + for (int attempt = 0; attempt < 20; attempt++) { | |
| 176 | + String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 4); | |
| 177 | + String candidate = base + "-" + suffix; | |
| 178 | + if (!accounts.existsByShareSlug(candidate)) { | |
| 179 | + return candidate; | |
| 180 | + } | |
| 181 | + } | |
| 182 | + return base + "-" + UUID.randomUUID().toString().substring(0, 8); | |
| 183 | + } | |
| 184 | + | |
| 185 | + private Account requiredAccount(UUID accountId) { | |
| 186 | + return accounts.findById(accountId) | |
| 187 | + .orElseThrow(() -> new NotFoundException("Account was not found.")); | |
| 188 | + } | |
| 189 | + | |
| 190 | + private AccountView view(Account account) { | |
| 191 | + return new AccountView( | |
| 192 | + account.id(), account.displayName(), account.email(), account.shareSlug(), | |
| 193 | + account.homeCity(), account.homeCountryCode(), account.bio(), account.avatarUrl(), | |
| 194 | + account.profilePublic(), account.createdAt() | |
| 195 | + ); | |
| 196 | + } | |
| 197 | + | |
| 198 | + private PublicAccountView publicView(Account account) { | |
| 199 | + return new PublicAccountView( | |
| 200 | + account.id(), account.displayName(), account.shareSlug(), account.homeCity(), | |
| 201 | + account.homeCountryCode(), account.bio(), account.avatarUrl(), account.createdAt() | |
| 202 | + ); | |
| 203 | + } | |
| 204 | + | |
| 205 | + private String hash(String value) { | |
| 206 | + try { | |
| 207 | + byte[] digest = MessageDigest.getInstance("SHA-256") | |
| 208 | + .digest(value.getBytes(StandardCharsets.UTF_8)); | |
| 209 | + return HexFormat.of().formatHex(digest); | |
| 210 | + } catch (NoSuchAlgorithmException exception) { | |
| 211 | + throw new IllegalStateException("SHA-256 is unavailable.", exception); | |
| 212 | + } | |
| 213 | + } | |
| 214 | +} |
added backend/src/main/java/com/tasteprint/account/AccountView.java +18 −0
| @@ -0,0 +1,18 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import java.time.Instant; | |
| 4 | +import java.util.UUID; | |
| 5 | + | |
| 6 | +public record AccountView( | |
| 7 | + UUID id, | |
| 8 | + String displayName, | |
| 9 | + String email, | |
| 10 | + String shareSlug, | |
| 11 | + String homeCity, | |
| 12 | + String homeCountryCode, | |
| 13 | + String bio, | |
| 14 | + String avatarUrl, | |
| 15 | + boolean profilePublic, | |
| 16 | + Instant memberSince | |
| 17 | +) { | |
| 18 | +} |
added backend/src/main/java/com/tasteprint/account/AuthController.java +69 −0
| @@ -0,0 +1,69 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import jakarta.validation.Valid; | |
| 4 | + | |
| 5 | +import org.springframework.http.HttpHeaders; | |
| 6 | +import org.springframework.http.HttpStatus; | |
| 7 | +import org.springframework.security.core.annotation.AuthenticationPrincipal; | |
| 8 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 9 | +import org.springframework.web.bind.annotation.DeleteMapping; | |
| 10 | +import org.springframework.web.bind.annotation.PatchMapping; | |
| 11 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 12 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 13 | +import org.springframework.web.bind.annotation.RequestHeader; | |
| 14 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 15 | +import org.springframework.web.bind.annotation.ResponseStatus; | |
| 16 | +import org.springframework.web.bind.annotation.RestController; | |
| 17 | + | |
| 18 | +@RestController | |
| 19 | +@RequestMapping("/api/v1/auth") | |
| 20 | +class AuthController { | |
| 21 | + | |
| 22 | + private final AccountService accounts; | |
| 23 | + | |
| 24 | + AuthController(AccountService accounts) { | |
| 25 | + this.accounts = accounts; | |
| 26 | + } | |
| 27 | + | |
| 28 | + @PostMapping("/register") | |
| 29 | + @ResponseStatus(HttpStatus.CREATED) | |
| 30 | + AuthSession register(@Valid @RequestBody RegisterRequest request) { | |
| 31 | + return accounts.register(request); | |
| 32 | + } | |
| 33 | + | |
| 34 | + @PostMapping("/login") | |
| 35 | + AuthSession login(@Valid @RequestBody LoginRequest request) { | |
| 36 | + return accounts.login(request); | |
| 37 | + } | |
| 38 | + | |
| 39 | + @PostMapping("/logout") | |
| 40 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 41 | + void logout(@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization) { | |
| 42 | + accounts.logout(bearerToken(authorization)); | |
| 43 | + } | |
| 44 | + | |
| 45 | + @GetMapping("/me") | |
| 46 | + AccountView me(@AuthenticationPrincipal AuthenticatedUser user) { | |
| 47 | + return accounts.get(user.id()); | |
| 48 | + } | |
| 49 | + | |
| 50 | + @PatchMapping("/me") | |
| 51 | + AccountView update(@AuthenticationPrincipal AuthenticatedUser user, | |
| 52 | + @Valid @RequestBody UpdateProfileRequest request) { | |
| 53 | + return accounts.update(user.id(), request); | |
| 54 | + } | |
| 55 | + | |
| 56 | + @DeleteMapping("/me") | |
| 57 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 58 | + void deleteAccount(@AuthenticationPrincipal AuthenticatedUser user, | |
| 59 | + @Valid @RequestBody DeleteAccountRequest request) { | |
| 60 | + accounts.delete(user.id(), request.password()); | |
| 61 | + } | |
| 62 | + | |
| 63 | + private String bearerToken(String authorization) { | |
| 64 | + if (authorization == null || !authorization.startsWith("Bearer ")) { | |
| 65 | + return null; | |
| 66 | + } | |
| 67 | + return authorization.substring(7).trim(); | |
| 68 | + } | |
| 69 | +} |
added backend/src/main/java/com/tasteprint/account/AuthSession.java +4 −0
| @@ -0,0 +1,4 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +public record AuthSession(String token, AccountView user) { | |
| 4 | +} |
added backend/src/main/java/com/tasteprint/account/AuthenticatedUser.java +6 −0
| @@ -0,0 +1,6 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import java.util.UUID; | |
| 4 | + | |
| 5 | +public record AuthenticatedUser(UUID id, String email, String displayName, String shareSlug) { | |
| 6 | +} |
added backend/src/main/java/com/tasteprint/account/DeleteAccountRequest.java +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.NotBlank; | |
| 4 | +import jakarta.validation.constraints.Size; | |
| 5 | + | |
| 6 | +public record DeleteAccountRequest( | |
| 7 | + @NotBlank @Size(max = 72) String password | |
| 8 | +) { | |
| 9 | +} |
added backend/src/main/java/com/tasteprint/account/LoginRequest.java +11 −0
| @@ -0,0 +1,11 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.Email; | |
| 4 | +import jakarta.validation.constraints.NotBlank; | |
| 5 | +import jakarta.validation.constraints.Size; | |
| 6 | + | |
| 7 | +public record LoginRequest( | |
| 8 | + @NotBlank @Email String email, | |
| 9 | + @NotBlank @Size(max = 72) String password | |
| 10 | +) { | |
| 11 | +} |
added backend/src/main/java/com/tasteprint/account/PublicAccountView.java +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import java.time.Instant; | |
| 4 | +import java.util.UUID; | |
| 5 | + | |
| 6 | +public record PublicAccountView( | |
| 7 | + UUID id, | |
| 8 | + String displayName, | |
| 9 | + String shareSlug, | |
| 10 | + String homeCity, | |
| 11 | + String homeCountryCode, | |
| 12 | + String bio, | |
| 13 | + String avatarUrl, | |
| 14 | + Instant memberSince | |
| 15 | +) { | |
| 16 | +} |
added backend/src/main/java/com/tasteprint/account/RegisterRequest.java +12 −0
| @@ -0,0 +1,12 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.Email; | |
| 4 | +import jakarta.validation.constraints.NotBlank; | |
| 5 | +import jakarta.validation.constraints.Size; | |
| 6 | + | |
| 7 | +public record RegisterRequest( | |
| 8 | + @NotBlank @Size(max = 80) String displayName, | |
| 9 | + @NotBlank @Email @Size(max = 254) String email, | |
| 10 | + @NotBlank @Size(min = 8, max = 72) String password | |
| 11 | +) { | |
| 12 | +} |
added backend/src/main/java/com/tasteprint/account/SecurityConfiguration.java +89 −0
| @@ -0,0 +1,89 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import java.io.IOException; | |
| 4 | +import java.net.URI; | |
| 5 | +import java.util.Arrays; | |
| 6 | +import java.util.List; | |
| 7 | + | |
| 8 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 9 | +import jakarta.servlet.DispatcherType; | |
| 10 | +import jakarta.servlet.http.HttpServletResponse; | |
| 11 | + | |
| 12 | +import org.springframework.beans.factory.annotation.Value; | |
| 13 | +import org.springframework.context.annotation.Bean; | |
| 14 | +import org.springframework.context.annotation.Configuration; | |
| 15 | +import org.springframework.http.HttpMethod; | |
| 16 | +import org.springframework.http.HttpStatus; | |
| 17 | +import org.springframework.http.MediaType; | |
| 18 | +import org.springframework.http.ProblemDetail; | |
| 19 | +import org.springframework.security.config.annotation.web.builders.HttpSecurity; | |
| 20 | +import org.springframework.security.config.http.SessionCreationPolicy; | |
| 21 | +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | |
| 22 | +import org.springframework.security.crypto.password.PasswordEncoder; | |
| 23 | +import org.springframework.security.web.SecurityFilterChain; | |
| 24 | +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; | |
| 25 | +import org.springframework.web.cors.CorsConfiguration; | |
| 26 | +import org.springframework.web.cors.CorsConfigurationSource; | |
| 27 | +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; | |
| 28 | + | |
| 29 | +@Configuration | |
| 30 | +class SecurityConfiguration { | |
| 31 | + | |
| 32 | + @Bean | |
| 33 | + PasswordEncoder passwordEncoder() { | |
| 34 | + return new BCryptPasswordEncoder(12); | |
| 35 | + } | |
| 36 | + | |
| 37 | + @Bean | |
| 38 | + SecurityFilterChain securityFilterChain(HttpSecurity http, TokenAuthenticationFilter tokenFilter, | |
| 39 | + ObjectMapper objectMapper) throws Exception { | |
| 40 | + return http | |
| 41 | + .csrf(csrf -> csrf.disable()) | |
| 42 | + .cors(cors -> { }) | |
| 43 | + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) | |
| 44 | + .authorizeHttpRequests(auth -> auth | |
| 45 | + .dispatcherTypeMatchers(DispatcherType.ERROR).permitAll() | |
| 46 | + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() | |
| 47 | + .requestMatchers(HttpMethod.POST, "/api/v1/auth/register", "/api/v1/auth/login").permitAll() | |
| 48 | + .requestMatchers(HttpMethod.GET, "/api/v1/catalog/**", "/api/v1/public/**", "/uploads/**").permitAll() | |
| 49 | + .requestMatchers("/actuator/health/**", "/v3/api-docs/**", "/docs", "/docs/**", "/swagger-ui/**").permitAll() | |
| 50 | + .anyRequest().authenticated()) | |
| 51 | + .exceptionHandling(errors -> errors | |
| 52 | + .authenticationEntryPoint((request, response, exception) -> | |
| 53 | + writeProblem(response, objectMapper, HttpStatus.UNAUTHORIZED, | |
| 54 | + "Authentication required", "Sign in to continue.")) | |
| 55 | + .accessDeniedHandler((request, response, exception) -> | |
| 56 | + writeProblem(response, objectMapper, HttpStatus.FORBIDDEN, | |
| 57 | + "Forbidden", "You do not have access to this resource."))) | |
| 58 | + .addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class) | |
| 59 | + .build(); | |
| 60 | + } | |
| 61 | + | |
| 62 | + @Bean | |
| 63 | + CorsConfigurationSource corsConfigurationSource( | |
| 64 | + @Value("${app.allowed-origins}") String allowedOrigins) { | |
| 65 | + CorsConfiguration configuration = new CorsConfiguration(); | |
| 66 | + configuration.setAllowedOrigins(Arrays.stream(allowedOrigins.split(",")) | |
| 67 | + .map(String::trim) | |
| 68 | + .filter(origin -> !origin.isBlank()) | |
| 69 | + .toList()); | |
| 70 | + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); | |
| 71 | + configuration.setAllowedHeaders(List.of("Authorization", "Content-Type", "Accept")); | |
| 72 | + configuration.setExposedHeaders(List.of("Location")); | |
| 73 | + configuration.setAllowCredentials(true); | |
| 74 | + configuration.setMaxAge(3600L); | |
| 75 | + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); | |
| 76 | + source.registerCorsConfiguration("/**", configuration); | |
| 77 | + return source; | |
| 78 | + } | |
| 79 | + | |
| 80 | + private void writeProblem(HttpServletResponse response, ObjectMapper mapper, HttpStatus status, | |
| 81 | + String title, String detail) throws IOException { | |
| 82 | + ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail); | |
| 83 | + problem.setTitle(title); | |
| 84 | + problem.setType(URI.create("https://tasteprint.app/problems/" + status.value())); | |
| 85 | + response.setStatus(status.value()); | |
| 86 | + response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE); | |
| 87 | + mapper.writeValue(response.getOutputStream(), problem); | |
| 88 | + } | |
| 89 | +} |
added backend/src/main/java/com/tasteprint/account/SessionToken.java +44 −0
| @@ -0,0 +1,44 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import java.time.Instant; | |
| 4 | +import java.util.UUID; | |
| 5 | + | |
| 6 | +import jakarta.persistence.Column; | |
| 7 | +import jakarta.persistence.Entity; | |
| 8 | +import jakarta.persistence.Id; | |
| 9 | +import jakarta.persistence.Table; | |
| 10 | + | |
| 11 | +@Entity | |
| 12 | +@Table(name = "session_token") | |
| 13 | +class SessionToken { | |
| 14 | + | |
| 15 | + @Id | |
| 16 | + private UUID id; | |
| 17 | + | |
| 18 | + @Column(name = "user_id", nullable = false) | |
| 19 | + private UUID userId; | |
| 20 | + | |
| 21 | + @Column(name = "token_hash", nullable = false, length = 64, unique = true) | |
| 22 | + private String tokenHash; | |
| 23 | + | |
| 24 | + @Column(name = "expires_at", nullable = false) | |
| 25 | + private Instant expiresAt; | |
| 26 | + | |
| 27 | + @Column(name = "created_at", nullable = false) | |
| 28 | + private Instant createdAt; | |
| 29 | + | |
| 30 | + protected SessionToken() { | |
| 31 | + } | |
| 32 | + | |
| 33 | + SessionToken(UUID id, UUID userId, String tokenHash, Instant expiresAt, Instant createdAt) { | |
| 34 | + this.id = id; | |
| 35 | + this.userId = userId; | |
| 36 | + this.tokenHash = tokenHash; | |
| 37 | + this.expiresAt = expiresAt; | |
| 38 | + this.createdAt = createdAt; | |
| 39 | + } | |
| 40 | + | |
| 41 | + UUID userId() { | |
| 42 | + return userId; | |
| 43 | + } | |
| 44 | +} |
added backend/src/main/java/com/tasteprint/account/SessionTokenRepository.java +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import java.time.Instant; | |
| 4 | +import java.util.Optional; | |
| 5 | +import java.util.UUID; | |
| 6 | + | |
| 7 | +import org.springframework.data.jpa.repository.JpaRepository; | |
| 8 | + | |
| 9 | +interface SessionTokenRepository extends JpaRepository<SessionToken, UUID> { | |
| 10 | + | |
| 11 | + Optional<SessionToken> findByTokenHashAndExpiresAtAfter(String tokenHash, Instant now); | |
| 12 | + | |
| 13 | + void deleteByTokenHash(String tokenHash); | |
| 14 | + | |
| 15 | + void deleteByExpiresAtBefore(Instant now); | |
| 16 | +} |
added backend/src/main/java/com/tasteprint/account/TokenAuthenticationFilter.java +49 −0
| @@ -0,0 +1,49 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import java.io.IOException; | |
| 4 | + | |
| 5 | +import jakarta.servlet.FilterChain; | |
| 6 | +import jakarta.servlet.ServletException; | |
| 7 | +import jakarta.servlet.http.HttpServletRequest; | |
| 8 | +import jakarta.servlet.http.HttpServletResponse; | |
| 9 | + | |
| 10 | +import org.springframework.http.HttpHeaders; | |
| 11 | +import org.springframework.security.authentication.BadCredentialsException; | |
| 12 | +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; | |
| 13 | +import org.springframework.security.core.context.SecurityContextHolder; | |
| 14 | +import org.springframework.stereotype.Component; | |
| 15 | +import org.springframework.web.filter.OncePerRequestFilter; | |
| 16 | + | |
| 17 | +@Component | |
| 18 | +class TokenAuthenticationFilter extends OncePerRequestFilter { | |
| 19 | + | |
| 20 | + private final AccountService accounts; | |
| 21 | + | |
| 22 | + TokenAuthenticationFilter(AccountService accounts) { | |
| 23 | + this.accounts = accounts; | |
| 24 | + } | |
| 25 | + | |
| 26 | + @Override | |
| 27 | + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, | |
| 28 | + FilterChain filterChain) throws ServletException, IOException { | |
| 29 | + String authorization = request.getHeader(HttpHeaders.AUTHORIZATION); | |
| 30 | + if (authorization != null && authorization.startsWith("Bearer ")) { | |
| 31 | + String token = authorization.substring(7).trim(); | |
| 32 | + if (!token.isEmpty()) { | |
| 33 | + try { | |
| 34 | + AuthenticatedUser user = accounts.authenticate(token); | |
| 35 | + SecurityContextHolder.getContext().setAuthentication( | |
| 36 | + UsernamePasswordAuthenticationToken.authenticated(user, token, userAuthorities()) | |
| 37 | + ); | |
| 38 | + } catch (BadCredentialsException ignored) { | |
| 39 | + SecurityContextHolder.clearContext(); | |
| 40 | + } | |
| 41 | + } | |
| 42 | + } | |
| 43 | + filterChain.doFilter(request, response); | |
| 44 | + } | |
| 45 | + | |
| 46 | + private java.util.List<org.springframework.security.core.GrantedAuthority> userAuthorities() { | |
| 47 | + return java.util.List.of(() -> "ROLE_USER"); | |
| 48 | + } | |
| 49 | +} |
added backend/src/main/java/com/tasteprint/account/UpdateProfileRequest.java +15 −0
| @@ -0,0 +1,15 @@ | ||
| 1 | +package com.tasteprint.account; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.NotBlank; | |
| 4 | +import jakarta.validation.constraints.Pattern; | |
| 5 | +import jakarta.validation.constraints.Size; | |
| 6 | + | |
| 7 | +public record UpdateProfileRequest( | |
| 8 | + @NotBlank @Size(max = 80) String displayName, | |
| 9 | + @Size(max = 100) String homeCity, | |
| 10 | + @Pattern(regexp = "^$|^[A-Za-z]{2}$", message = "must be a two-letter country code") String homeCountryCode, | |
| 11 | + @Size(max = 280) String bio, | |
| 12 | + @Size(max = 500) @Pattern(regexp = "^$|^https://.+|^/uploads/[A-Za-z0-9._-]+$", message = "must be an HTTPS URL or an uploaded photo") String avatarUrl, | |
| 13 | + boolean profilePublic | |
| 14 | +) { | |
| 15 | +} |
added backend/src/main/java/com/tasteprint/account/package-info.java +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +@org.springframework.modulith.ApplicationModule( | |
| 2 | + displayName = "Accounts and sessions", | |
| 3 | + allowedDependencies = "shared" | |
| 4 | +) | |
| 5 | +package com.tasteprint.account; |
added backend/src/main/java/com/tasteprint/catalog/CatalogController.java +40 −0
| @@ -0,0 +1,40 @@ | ||
| 1 | +package com.tasteprint.catalog; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 6 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 7 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 8 | +import org.springframework.web.bind.annotation.RequestParam; | |
| 9 | +import org.springframework.web.bind.annotation.RestController; | |
| 10 | + | |
| 11 | +@RestController | |
| 12 | +@RequestMapping("/api/v1/catalog") | |
| 13 | +class CatalogController { | |
| 14 | + | |
| 15 | + private final CatalogService catalog; | |
| 16 | + | |
| 17 | + CatalogController(CatalogService catalog) { | |
| 18 | + this.catalog = catalog; | |
| 19 | + } | |
| 20 | + | |
| 21 | + @GetMapping("/destinations") | |
| 22 | + List<DestinationSummary> destinations() { | |
| 23 | + return catalog.destinations(); | |
| 24 | + } | |
| 25 | + | |
| 26 | + @GetMapping("/destinations/{code}") | |
| 27 | + DestinationDetails destination(@PathVariable String code) { | |
| 28 | + return catalog.destination(code); | |
| 29 | + } | |
| 30 | + | |
| 31 | + @GetMapping("/dishes/{slug}") | |
| 32 | + DishView dish(@PathVariable String slug) { | |
| 33 | + return catalog.dish(slug); | |
| 34 | + } | |
| 35 | + | |
| 36 | + @GetMapping("/dishes") | |
| 37 | + List<DishView> search(@RequestParam(defaultValue = "") String query) { | |
| 38 | + return catalog.search(query); | |
| 39 | + } | |
| 40 | +} |
added backend/src/main/java/com/tasteprint/catalog/CatalogService.java +123 −0
| @@ -0,0 +1,123 @@ | ||
| 1 | +package com.tasteprint.catalog; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | +import java.util.Locale; | |
| 5 | +import java.util.Map; | |
| 6 | +import java.util.function.Function; | |
| 7 | +import java.util.stream.Collectors; | |
| 8 | + | |
| 9 | +import org.springframework.data.domain.PageRequest; | |
| 10 | +import org.springframework.stereotype.Service; | |
| 11 | +import org.springframework.transaction.annotation.Transactional; | |
| 12 | + | |
| 13 | +import com.tasteprint.shared.NotFoundException; | |
| 14 | + | |
| 15 | +@Service | |
| 16 | +public class CatalogService { | |
| 17 | + | |
| 18 | + private final DestinationRepository destinations; | |
| 19 | + private final DishRepository dishes; | |
| 20 | + | |
| 21 | + CatalogService(DestinationRepository destinations, DishRepository dishes) { | |
| 22 | + this.destinations = destinations; | |
| 23 | + this.dishes = dishes; | |
| 24 | + } | |
| 25 | + | |
| 26 | + @Transactional(readOnly = true) | |
| 27 | + public List<DestinationSummary> destinations() { | |
| 28 | + Map<String, Long> counts = dishes.findAll().stream() | |
| 29 | + .collect(Collectors.groupingBy(Dish::destinationCode, Collectors.counting())); | |
| 30 | + return destinations.findAllByOrderByDisplayOrderAsc().stream() | |
| 31 | + .map(destination -> summary(destination, counts.getOrDefault(destination.code(), 0L).intValue())) | |
| 32 | + .toList(); | |
| 33 | + } | |
| 34 | + | |
| 35 | + @Transactional(readOnly = true) | |
| 36 | + public DestinationDetails destination(String code) { | |
| 37 | + String normalizedCode = normalizeCountryCode(code); | |
| 38 | + Destination destination = requiredDestination(normalizedCode); | |
| 39 | + List<Dish> destinationDishes = dishes.findByDestinationCodeOrderByDisplayOrderAsc(normalizedCode); | |
| 40 | + return new DestinationDetails( | |
| 41 | + summary(destination, destinationDishes.size()), | |
| 42 | + destinationDishes.stream().map(dish -> view(dish, destination)).toList() | |
| 43 | + ); | |
| 44 | + } | |
| 45 | + | |
| 46 | + @Transactional(readOnly = true) | |
| 47 | + public DishView dish(String slug) { | |
| 48 | + Dish dish = requiredDish(slug); | |
| 49 | + return view(dish, requiredDestination(dish.destinationCode())); | |
| 50 | + } | |
| 51 | + | |
| 52 | + @Transactional(readOnly = true) | |
| 53 | + public List<DishView> dishesFor(String destinationCode) { | |
| 54 | + Destination destination = requiredDestination(normalizeCountryCode(destinationCode)); | |
| 55 | + return dishes.findByDestinationCodeOrderByDisplayOrderAsc(destination.code()).stream() | |
| 56 | + .map(dish -> view(dish, destination)) | |
| 57 | + .toList(); | |
| 58 | + } | |
| 59 | + | |
| 60 | + @Transactional(readOnly = true) | |
| 61 | + public List<DishView> allDishes() { | |
| 62 | + Map<String, Destination> byCode = destinations.findAll().stream() | |
| 63 | + .collect(Collectors.toMap(Destination::code, Function.identity())); | |
| 64 | + return dishes.findAll().stream() | |
| 65 | + .map(dish -> view(dish, byCode.get(dish.destinationCode()))) | |
| 66 | + .toList(); | |
| 67 | + } | |
| 68 | + | |
| 69 | + @Transactional(readOnly = true) | |
| 70 | + public List<DishView> search(String query) { | |
| 71 | + if (query == null || query.isBlank()) { | |
| 72 | + return List.of(); | |
| 73 | + } | |
| 74 | + Map<String, Destination> byCode = destinations.findAll().stream() | |
| 75 | + .collect(Collectors.toMap(Destination::code, Function.identity())); | |
| 76 | + return dishes.search(query.trim(), PageRequest.of(0, 30)).stream() | |
| 77 | + .map(dish -> view(dish, byCode.get(dish.destinationCode()))) | |
| 78 | + .toList(); | |
| 79 | + } | |
| 80 | + | |
| 81 | + @Transactional(readOnly = true) | |
| 82 | + public void requireDestination(String code) { | |
| 83 | + requiredDestination(normalizeCountryCode(code)); | |
| 84 | + } | |
| 85 | + | |
| 86 | + @Transactional(readOnly = true) | |
| 87 | + public void requireDish(String slug) { | |
| 88 | + requiredDish(slug); | |
| 89 | + } | |
| 90 | + | |
| 91 | + private Destination requiredDestination(String code) { | |
| 92 | + return destinations.findById(code) | |
| 93 | + .orElseThrow(() -> new NotFoundException("Destination " + code + " is not in the catalog.")); | |
| 94 | + } | |
| 95 | + | |
| 96 | + private Dish requiredDish(String slug) { | |
| 97 | + return dishes.findById(slug) | |
| 98 | + .orElseThrow(() -> new NotFoundException("Dish was not found in the catalog.")); | |
| 99 | + } | |
| 100 | + | |
| 101 | + private String normalizeCountryCode(String code) { | |
| 102 | + if (code == null || !code.matches("(?i)[a-z]{2}")) { | |
| 103 | + throw new IllegalArgumentException("Destination must be a two-letter country code."); | |
| 104 | + } | |
| 105 | + return code.toUpperCase(Locale.ROOT); | |
| 106 | + } | |
| 107 | + | |
| 108 | + private DestinationSummary summary(Destination destination, int dishCount) { | |
| 109 | + return new DestinationSummary( | |
| 110 | + destination.code(), destination.name(), destination.localName(), destination.regionName(), | |
| 111 | + destination.summary(), destination.centerLat(), destination.centerLng(), | |
| 112 | + destination.accentColor(), dishCount | |
| 113 | + ); | |
| 114 | + } | |
| 115 | + | |
| 116 | + private DishView view(Dish dish, Destination destination) { | |
| 117 | + return new DishView( | |
| 118 | + dish.slug(), dish.destinationCode(), destination.name(), destination.accentColor(), | |
| 119 | + dish.name(), dish.localName(), dish.category(), dish.category().label(), dish.description(), | |
| 120 | + dish.whyItMatters(), dish.importance(), dish.imageUrl(), dish.vegetarian(), dish.spicyLevel() | |
| 121 | + ); | |
| 122 | + } | |
| 123 | +} |
added backend/src/main/java/com/tasteprint/catalog/Destination.java +74 −0
| @@ -0,0 +1,74 @@ | ||
| 1 | +package com.tasteprint.catalog; | |
| 2 | + | |
| 3 | +import jakarta.persistence.Column; | |
| 4 | +import jakarta.persistence.Entity; | |
| 5 | +import jakarta.persistence.Id; | |
| 6 | +import jakarta.persistence.Table; | |
| 7 | + | |
| 8 | +@Entity | |
| 9 | +@Table(name = "destination") | |
| 10 | +class Destination { | |
| 11 | + | |
| 12 | + @Id | |
| 13 | + @Column(length = 2) | |
| 14 | + private String code; | |
| 15 | + | |
| 16 | + @Column(nullable = false, length = 100) | |
| 17 | + private String name; | |
| 18 | + | |
| 19 | + @Column(name = "local_name", nullable = false, length = 100) | |
| 20 | + private String localName; | |
| 21 | + | |
| 22 | + @Column(name = "region_name", nullable = false, length = 100) | |
| 23 | + private String regionName; | |
| 24 | + | |
| 25 | + @Column(nullable = false, length = 500) | |
| 26 | + private String summary; | |
| 27 | + | |
| 28 | + @Column(name = "center_lat", nullable = false) | |
| 29 | + private double centerLat; | |
| 30 | + | |
| 31 | + @Column(name = "center_lng", nullable = false) | |
| 32 | + private double centerLng; | |
| 33 | + | |
| 34 | + @Column(name = "accent_color", nullable = false, length = 7) | |
| 35 | + private String accentColor; | |
| 36 | + | |
| 37 | + @Column(name = "display_order", nullable = false) | |
| 38 | + private int displayOrder; | |
| 39 | + | |
| 40 | + protected Destination() { | |
| 41 | + } | |
| 42 | + | |
| 43 | + String code() { | |
| 44 | + return code; | |
| 45 | + } | |
| 46 | + | |
| 47 | + String name() { | |
| 48 | + return name; | |
| 49 | + } | |
| 50 | + | |
| 51 | + String localName() { | |
| 52 | + return localName; | |
| 53 | + } | |
| 54 | + | |
| 55 | + String regionName() { | |
| 56 | + return regionName; | |
| 57 | + } | |
| 58 | + | |
| 59 | + String summary() { | |
| 60 | + return summary; | |
| 61 | + } | |
| 62 | + | |
| 63 | + double centerLat() { | |
| 64 | + return centerLat; | |
| 65 | + } | |
| 66 | + | |
| 67 | + double centerLng() { | |
| 68 | + return centerLng; | |
| 69 | + } | |
| 70 | + | |
| 71 | + String accentColor() { | |
| 72 | + return accentColor; | |
| 73 | + } | |
| 74 | +} |
added backend/src/main/java/com/tasteprint/catalog/DestinationDetails.java +6 −0
| @@ -0,0 +1,6 @@ | ||
| 1 | +package com.tasteprint.catalog; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +public record DestinationDetails(DestinationSummary destination, List<DishView> dishes) { | |
| 6 | +} |
added backend/src/main/java/com/tasteprint/catalog/DestinationRepository.java +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +package com.tasteprint.catalog; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +import org.springframework.data.jpa.repository.JpaRepository; | |
| 6 | + | |
| 7 | +interface DestinationRepository extends JpaRepository<Destination, String> { | |
| 8 | + | |
| 9 | + List<Destination> findAllByOrderByDisplayOrderAsc(); | |
| 10 | +} |
added backend/src/main/java/com/tasteprint/catalog/DestinationSummary.java +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +package com.tasteprint.catalog; | |
| 2 | + | |
| 3 | +public record DestinationSummary( | |
| 4 | + String code, | |
| 5 | + String name, | |
| 6 | + String localName, | |
| 7 | + String regionName, | |
| 8 | + String summary, | |
| 9 | + double centerLat, | |
| 10 | + double centerLng, | |
| 11 | + String accentColor, | |
| 12 | + int dishCount | |
| 13 | +) { | |
| 14 | +} |
added backend/src/main/java/com/tasteprint/catalog/Dish.java +98 −0
| @@ -0,0 +1,98 @@ | ||
| 1 | +package com.tasteprint.catalog; | |
| 2 | + | |
| 3 | +import jakarta.persistence.Column; | |
| 4 | +import jakarta.persistence.Entity; | |
| 5 | +import jakarta.persistence.EnumType; | |
| 6 | +import jakarta.persistence.Enumerated; | |
| 7 | +import jakarta.persistence.Id; | |
| 8 | +import jakarta.persistence.Table; | |
| 9 | + | |
| 10 | +@Entity | |
| 11 | +@Table(name = "dish") | |
| 12 | +class Dish { | |
| 13 | + | |
| 14 | + @Id | |
| 15 | + @Column(length = 120) | |
| 16 | + private String slug; | |
| 17 | + | |
| 18 | + @Column(name = "destination_code", nullable = false, length = 2) | |
| 19 | + private String destinationCode; | |
| 20 | + | |
| 21 | + @Column(nullable = false, length = 140) | |
| 22 | + private String name; | |
| 23 | + | |
| 24 | + @Column(name = "local_name", length = 140) | |
| 25 | + private String localName; | |
| 26 | + | |
| 27 | + @Enumerated(EnumType.STRING) | |
| 28 | + @Column(nullable = false, length = 30) | |
| 29 | + private DishCategory category; | |
| 30 | + | |
| 31 | + @Column(nullable = false, length = 500) | |
| 32 | + private String description; | |
| 33 | + | |
| 34 | + @Column(name = "why_it_matters", nullable = false, length = 500) | |
| 35 | + private String whyItMatters; | |
| 36 | + | |
| 37 | + @Column(nullable = false) | |
| 38 | + private short importance; | |
| 39 | + | |
| 40 | + @Column(name = "image_url", length = 500) | |
| 41 | + private String imageUrl; | |
| 42 | + | |
| 43 | + @Column(nullable = false) | |
| 44 | + private boolean vegetarian; | |
| 45 | + | |
| 46 | + @Column(name = "spicy_level", nullable = false) | |
| 47 | + private short spicyLevel; | |
| 48 | + | |
| 49 | + @Column(name = "display_order", nullable = false) | |
| 50 | + private int displayOrder; | |
| 51 | + | |
| 52 | + protected Dish() { | |
| 53 | + } | |
| 54 | + | |
| 55 | + String slug() { | |
| 56 | + return slug; | |
| 57 | + } | |
| 58 | + | |
| 59 | + String destinationCode() { | |
| 60 | + return destinationCode; | |
| 61 | + } | |
| 62 | + | |
| 63 | + String name() { | |
| 64 | + return name; | |
| 65 | + } | |
| 66 | + | |
| 67 | + String localName() { | |
| 68 | + return localName; | |
| 69 | + } | |
| 70 | + | |
| 71 | + DishCategory category() { | |
| 72 | + return category; | |
| 73 | + } | |
| 74 | + | |
| 75 | + String description() { | |
| 76 | + return description; | |
| 77 | + } | |
| 78 | + | |
| 79 | + String whyItMatters() { | |
| 80 | + return whyItMatters; | |
| 81 | + } | |
| 82 | + | |
| 83 | + int importance() { | |
| 84 | + return importance; | |
| 85 | + } | |
| 86 | + | |
| 87 | + String imageUrl() { | |
| 88 | + return imageUrl; | |
| 89 | + } | |
| 90 | + | |
| 91 | + boolean vegetarian() { | |
| 92 | + return vegetarian; | |
| 93 | + } | |
| 94 | + | |
| 95 | + int spicyLevel() { | |
| 96 | + return spicyLevel; | |
| 97 | + } | |
| 98 | +} |
added backend/src/main/java/com/tasteprint/catalog/DishCategory.java +20 −0
| @@ -0,0 +1,20 @@ | ||
| 1 | +package com.tasteprint.catalog; | |
| 2 | + | |
| 3 | +public enum DishCategory { | |
| 4 | + STAPLE("Everyday table"), | |
| 5 | + STREET("Street bite"), | |
| 6 | + BREAKFAST("Morning ritual"), | |
| 7 | + SWEET("Sweet finish"), | |
| 8 | + DRINK("Local pour"), | |
| 9 | + SIGNATURE("Defining dish"); | |
| 10 | + | |
| 11 | + private final String label; | |
| 12 | + | |
| 13 | + DishCategory(String label) { | |
| 14 | + this.label = label; | |
| 15 | + } | |
| 16 | + | |
| 17 | + public String label() { | |
| 18 | + return label; | |
| 19 | + } | |
| 20 | +} |
added backend/src/main/java/com/tasteprint/catalog/DishRepository.java +21 −0
| @@ -0,0 +1,21 @@ | ||
| 1 | +package com.tasteprint.catalog; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +import org.springframework.data.domain.Pageable; | |
| 6 | +import org.springframework.data.jpa.repository.JpaRepository; | |
| 7 | +import org.springframework.data.jpa.repository.Query; | |
| 8 | +import org.springframework.data.repository.query.Param; | |
| 9 | + | |
| 10 | +interface DishRepository extends JpaRepository<Dish, String> { | |
| 11 | + | |
| 12 | + List<Dish> findByDestinationCodeOrderByDisplayOrderAsc(String destinationCode); | |
| 13 | + | |
| 14 | + @Query(""" | |
| 15 | + select d from Dish d | |
| 16 | + where lower(d.name) like lower(concat('%', :query, '%')) | |
| 17 | + or lower(coalesce(d.localName, '')) like lower(concat('%', :query, '%')) | |
| 18 | + order by d.importance desc, d.name asc | |
| 19 | + """) | |
| 20 | + List<Dish> search(@Param("query") String query, Pageable pageable); | |
| 21 | +} |
added backend/src/main/java/com/tasteprint/catalog/DishView.java +19 −0
| @@ -0,0 +1,19 @@ | ||
| 1 | +package com.tasteprint.catalog; | |
| 2 | + | |
| 3 | +public record DishView( | |
| 4 | + String slug, | |
| 5 | + String destinationCode, | |
| 6 | + String destinationName, | |
| 7 | + String destinationAccent, | |
| 8 | + String name, | |
| 9 | + String localName, | |
| 10 | + DishCategory category, | |
| 11 | + String categoryLabel, | |
| 12 | + String description, | |
| 13 | + String whyItMatters, | |
| 14 | + int importance, | |
| 15 | + String imageUrl, | |
| 16 | + boolean vegetarian, | |
| 17 | + int spicyLevel | |
| 18 | +) { | |
| 19 | +} |
added backend/src/main/java/com/tasteprint/catalog/package-info.java +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +@org.springframework.modulith.ApplicationModule( | |
| 2 | + displayName = "Culinary catalog", | |
| 3 | + allowedDependencies = "shared" | |
| 4 | +) | |
| 5 | +package com.tasteprint.catalog; |
added backend/src/main/java/com/tasteprint/demo/DemoDataInitializer.java +120 −0
| @@ -0,0 +1,120 @@ | ||
| 1 | +package com.tasteprint.demo; | |
| 2 | + | |
| 3 | +import java.time.Clock; | |
| 4 | +import java.time.LocalDate; | |
| 5 | +import java.util.UUID; | |
| 6 | + | |
| 7 | +import org.springframework.boot.ApplicationArguments; | |
| 8 | +import org.springframework.boot.ApplicationRunner; | |
| 9 | +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; | |
| 10 | +import org.springframework.stereotype.Component; | |
| 11 | + | |
| 12 | +import com.tasteprint.account.AccountService; | |
| 13 | +import com.tasteprint.account.UpdateProfileRequest; | |
| 14 | +import com.tasteprint.journey.SaveTripRequest; | |
| 15 | +import com.tasteprint.journey.TripService; | |
| 16 | +import com.tasteprint.social.ChallengeService; | |
| 17 | +import com.tasteprint.social.ChallengeView; | |
| 18 | +import com.tasteprint.social.CreateChallengeRequest; | |
| 19 | +import com.tasteprint.tasting.SaveTastingRequest; | |
| 20 | +import com.tasteprint.tasting.TastingService; | |
| 21 | + | |
| 22 | +@Component | |
| 23 | +@ConditionalOnProperty(name = "app.demo-data-enabled", havingValue = "true") | |
| 24 | +class DemoDataInitializer implements ApplicationRunner { | |
| 25 | + | |
| 26 | + private final AccountService accounts; | |
| 27 | + private final TastingService tastings; | |
| 28 | + private final TripService trips; | |
| 29 | + private final ChallengeService challenges; | |
| 30 | + private final Clock clock; | |
| 31 | + | |
| 32 | + DemoDataInitializer(AccountService accounts, TastingService tastings, | |
| 33 | + TripService trips, ChallengeService challenges, Clock clock) { | |
| 34 | + this.accounts = accounts; | |
| 35 | + this.tastings = tastings; | |
| 36 | + this.trips = trips; | |
| 37 | + this.challenges = challenges; | |
| 38 | + this.clock = clock; | |
| 39 | + } | |
| 40 | + | |
| 41 | + @Override | |
| 42 | + public void run(ApplicationArguments args) { | |
| 43 | + UUID demoUser = accounts.ensureDemoAccount( | |
| 44 | + "Rasmus", "demo@tasteprint.app", "tasteprint", true | |
| 45 | + ); | |
| 46 | + UUID maria = accounts.ensureDemoAccount( | |
| 47 | + "Maria", "maria@tasteprint.app", "tasteprint", true | |
| 48 | + ); | |
| 49 | + accounts.update(demoUser, new UpdateProfileRequest( | |
| 50 | + "Rasmus", "Tallinn", "EE", "I plan trips around the dishes I have not tried yet.", null, true | |
| 51 | + )); | |
| 52 | + accounts.update(maria, new UpdateProfileRequest( | |
| 53 | + "Maria", "Lisbon", "PT", "Street food first, museum second.", null, true | |
| 54 | + )); | |
| 55 | + | |
| 56 | + LocalDate today = LocalDate.now(clock); | |
| 57 | + if (!tastings.hasAny(demoUser)) { | |
| 58 | + seedDemoTastings(demoUser, today); | |
| 59 | + } | |
| 60 | + if (!tastings.hasAny(maria)) { | |
| 61 | + seedMariaTastings(maria, today); | |
| 62 | + } | |
| 63 | + if (!trips.hasAny(demoUser)) { | |
| 64 | + trips.create(demoUser, new SaveTripRequest( | |
| 65 | + "JP", "Osaka", today.plusDays(24), today.plusDays(31) | |
| 66 | + )); | |
| 67 | + } | |
| 68 | + if (!challenges.hasAny(demoUser)) { | |
| 69 | + ChallengeView challenge = challenges.create(demoUser, new CreateChallengeRequest( | |
| 70 | + "Japan beyond sushi", "JP", today.minusDays(14), today.plusDays(30) | |
| 71 | + )); | |
| 72 | + challenges.join(maria, challenge.joinCode()); | |
| 73 | + } | |
| 74 | + } | |
| 75 | + | |
| 76 | + private void seedDemoTastings(UUID userId, LocalDate today) { | |
| 77 | + record(userId, "ramen", "Tokyo", "JP", today.minusDays(210), 5, | |
| 78 | + "The broth changed what I thought ramen was.", | |
| 79 | + "https://images.unsplash.com/photo-1569718212165-3a8278d5f624?auto=format&fit=crop&w=1200&q=80"); | |
| 80 | + record(userId, "sushi", "Tokyo", "JP", today.minusDays(208), 5, | |
| 81 | + "The rice temperature was the detail I had never noticed.", | |
| 82 | + "https://images.unsplash.com/photo-1579871494447-9811cf80d66c?auto=format&fit=crop&w=1200&q=80"); | |
| 83 | + record(userId, "tacos-al-pastor", "Mexico City", "MX", today.minusDays(145), 5, | |
| 84 | + "Late-night counter, pineapple, and a very fast second order.", | |
| 85 | + "https://images.unsplash.com/photo-1551504734-5ee1c4a1479b?auto=format&fit=crop&w=1200&q=80"); | |
| 86 | + record(userId, "chilaquiles", "Mexico City", "MX", today.minusDays(143), 4, | |
| 87 | + "Breakfast that made the previous night feel manageable.", null); | |
| 88 | + record(userId, "pastel-de-nata", "Lisbon", "PT", today.minusDays(94), 5, | |
| 89 | + "Still warm, with cinnamon and an espresso.", null); | |
| 90 | + record(userId, "pho", "Hanoi", "VN", today.minusDays(63), 5, | |
| 91 | + "A quiet morning bowl with herbs added one pinch at a time.", | |
| 92 | + "https://images.unsplash.com/photo-1582878826629-29b7ad1cdc43?auto=format&fit=crop&w=1200&q=80"); | |
| 93 | + record(userId, "khachapuri", "Tallinn", "EE", today.minusDays(35), 4, | |
| 94 | + "Tried the Adjarian version close to home.", null); | |
| 95 | + record(userId, "kama", "Tallinn", "EE", today.minusDays(22), 4, | |
| 96 | + "Best with tart kefir and berries.", null); | |
| 97 | + record(userId, "kiluleib", "Tallinn", "EE", today.minusDays(12), 5, | |
| 98 | + "Rye, sprat, egg, onion. Nothing extra needed.", null); | |
| 99 | + record(userId, "matcha", "Tallinn", "EE", today.minusDays(7), 4, | |
| 100 | + "Earthier and less bitter than the versions I knew.", null); | |
| 101 | + } | |
| 102 | + | |
| 103 | + private void seedMariaTastings(UUID userId, LocalDate today) { | |
| 104 | + record(userId, "ramen", "Kyoto", "JP", today.minusDays(180), 4, null, null); | |
| 105 | + record(userId, "takoyaki", "Osaka", "JP", today.minusDays(178), 5, null, null); | |
| 106 | + record(userId, "sushi", "Tokyo", "JP", today.minusDays(175), 5, null, null); | |
| 107 | + record(userId, "pad-kra-pao", "Bangkok", "TH", today.minusDays(88), 5, null, null); | |
| 108 | + record(userId, "mango-sticky-rice", "Bangkok", "TH", today.minusDays(86), 4, null, null); | |
| 109 | + record(userId, "ceviche", "Lima", "PE", today.minusDays(44), 5, null, null); | |
| 110 | + record(userId, "pastel-de-nata", "Lisbon", "PT", today.minusDays(20), 5, null, null); | |
| 111 | + record(userId, "daifuku", "Lisbon", "PT", today.minusDays(5), 4, null, null); | |
| 112 | + } | |
| 113 | + | |
| 114 | + private void record(UUID userId, String dishSlug, String city, String countryCode, | |
| 115 | + LocalDate date, int rating, String note, String photoUrl) { | |
| 116 | + tastings.record(userId, new SaveTastingRequest( | |
| 117 | + dishSlug, null, city, countryCode, date, rating, note, photoUrl, null, null | |
| 118 | + )); | |
| 119 | + } | |
| 120 | +} |
added backend/src/main/java/com/tasteprint/demo/package-info.java +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +@org.springframework.modulith.ApplicationModule( | |
| 2 | + displayName = "Demo data", | |
| 3 | + allowedDependencies = {"account", "catalog", "journey", "social", "tasting"} | |
| 4 | +) | |
| 5 | +package com.tasteprint.demo; |
added backend/src/main/java/com/tasteprint/journey/MissionItemView.java +6 −0
| @@ -0,0 +1,6 @@ | ||
| 1 | +package com.tasteprint.journey; | |
| 2 | + | |
| 3 | +import com.tasteprint.catalog.DishView; | |
| 4 | + | |
| 5 | +public record MissionItemView(int position, DishView dish, boolean completed) { | |
| 6 | +} |
added backend/src/main/java/com/tasteprint/journey/SaveTripRequest.java +27 −0
| @@ -0,0 +1,27 @@ | ||
| 1 | +package com.tasteprint.journey; | |
| 2 | + | |
| 3 | +import java.time.LocalDate; | |
| 4 | +import java.time.temporal.ChronoUnit; | |
| 5 | + | |
| 6 | +import jakarta.validation.constraints.NotBlank; | |
| 7 | +import jakarta.validation.constraints.NotNull; | |
| 8 | +import jakarta.validation.constraints.Pattern; | |
| 9 | +import jakarta.validation.constraints.Size; | |
| 10 | + | |
| 11 | +public record SaveTripRequest( | |
| 12 | + @NotBlank @Pattern(regexp = "^[A-Za-z]{2}$", message = "must be a two-letter country code") String destinationCode, | |
| 13 | + @NotBlank @Size(max = 100) String city, | |
| 14 | + @NotNull LocalDate startsOn, | |
| 15 | + @NotNull LocalDate endsOn | |
| 16 | +) { | |
| 17 | + public SaveTripRequest { | |
| 18 | + if (startsOn != null && endsOn != null) { | |
| 19 | + if (endsOn.isBefore(startsOn)) { | |
| 20 | + throw new IllegalArgumentException("Trip end date cannot be before its start date."); | |
| 21 | + } | |
| 22 | + if (ChronoUnit.DAYS.between(startsOn, endsOn) > 180) { | |
| 23 | + throw new IllegalArgumentException("A trip can be at most 180 days long."); | |
| 24 | + } | |
| 25 | + } | |
| 26 | + } | |
| 27 | +} |
added backend/src/main/java/com/tasteprint/journey/Trip.java +81 −0
| @@ -0,0 +1,81 @@ | ||
| 1 | +package com.tasteprint.journey; | |
| 2 | + | |
| 3 | +import java.time.Instant; | |
| 4 | +import java.time.LocalDate; | |
| 5 | +import java.util.UUID; | |
| 6 | + | |
| 7 | +import jakarta.persistence.Column; | |
| 8 | +import jakarta.persistence.Entity; | |
| 9 | +import jakarta.persistence.Id; | |
| 10 | +import jakarta.persistence.Table; | |
| 11 | + | |
| 12 | +@Entity | |
| 13 | +@Table(name = "trip") | |
| 14 | +class Trip { | |
| 15 | + | |
| 16 | + @Id | |
| 17 | + private UUID id; | |
| 18 | + | |
| 19 | + @Column(name = "user_id", nullable = false) | |
| 20 | + private UUID userId; | |
| 21 | + | |
| 22 | + @Column(name = "destination_code", nullable = false, length = 2) | |
| 23 | + private String destinationCode; | |
| 24 | + | |
| 25 | + @Column(nullable = false, length = 100) | |
| 26 | + private String city; | |
| 27 | + | |
| 28 | + @Column(name = "starts_on", nullable = false) | |
| 29 | + private LocalDate startsOn; | |
| 30 | + | |
| 31 | + @Column(name = "ends_on", nullable = false) | |
| 32 | + private LocalDate endsOn; | |
| 33 | + | |
| 34 | + @Column(name = "created_at", nullable = false) | |
| 35 | + private Instant createdAt; | |
| 36 | + | |
| 37 | + @Column(name = "updated_at", nullable = false) | |
| 38 | + private Instant updatedAt; | |
| 39 | + | |
| 40 | + protected Trip() { | |
| 41 | + } | |
| 42 | + | |
| 43 | + Trip(UUID id, UUID userId, String destinationCode, SaveTripRequest request, Instant now) { | |
| 44 | + this.id = id; | |
| 45 | + this.userId = userId; | |
| 46 | + this.destinationCode = destinationCode; | |
| 47 | + this.createdAt = now; | |
| 48 | + update(request, now); | |
| 49 | + } | |
| 50 | + | |
| 51 | + void update(SaveTripRequest request, Instant now) { | |
| 52 | + this.city = request.city().trim(); | |
| 53 | + this.startsOn = request.startsOn(); | |
| 54 | + this.endsOn = request.endsOn(); | |
| 55 | + this.updatedAt = now; | |
| 56 | + } | |
| 57 | + | |
| 58 | + UUID id() { | |
| 59 | + return id; | |
| 60 | + } | |
| 61 | + | |
| 62 | + UUID userId() { | |
| 63 | + return userId; | |
| 64 | + } | |
| 65 | + | |
| 66 | + String destinationCode() { | |
| 67 | + return destinationCode; | |
| 68 | + } | |
| 69 | + | |
| 70 | + String city() { | |
| 71 | + return city; | |
| 72 | + } | |
| 73 | + | |
| 74 | + LocalDate startsOn() { | |
| 75 | + return startsOn; | |
| 76 | + } | |
| 77 | + | |
| 78 | + LocalDate endsOn() { | |
| 79 | + return endsOn; | |
| 80 | + } | |
| 81 | +} |
added backend/src/main/java/com/tasteprint/journey/TripController.java +61 −0
| @@ -0,0 +1,61 @@ | ||
| 1 | +package com.tasteprint.journey; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | +import java.util.UUID; | |
| 5 | + | |
| 6 | +import jakarta.validation.Valid; | |
| 7 | + | |
| 8 | +import org.springframework.http.HttpStatus; | |
| 9 | +import org.springframework.security.core.annotation.AuthenticationPrincipal; | |
| 10 | +import org.springframework.web.bind.annotation.DeleteMapping; | |
| 11 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 12 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 13 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 14 | +import org.springframework.web.bind.annotation.PutMapping; | |
| 15 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 16 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 17 | +import org.springframework.web.bind.annotation.ResponseStatus; | |
| 18 | +import org.springframework.web.bind.annotation.RestController; | |
| 19 | + | |
| 20 | +import com.tasteprint.account.AuthenticatedUser; | |
| 21 | + | |
| 22 | +@RestController | |
| 23 | +@RequestMapping("/api/v1/trips") | |
| 24 | +class TripController { | |
| 25 | + | |
| 26 | + private final TripService trips; | |
| 27 | + | |
| 28 | + TripController(TripService trips) { | |
| 29 | + this.trips = trips; | |
| 30 | + } | |
| 31 | + | |
| 32 | + @GetMapping | |
| 33 | + List<TripView> list(@AuthenticationPrincipal AuthenticatedUser user) { | |
| 34 | + return trips.list(user.id()); | |
| 35 | + } | |
| 36 | + | |
| 37 | + @GetMapping("/{tripId}") | |
| 38 | + TripView get(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable UUID tripId) { | |
| 39 | + return trips.get(user.id(), tripId); | |
| 40 | + } | |
| 41 | + | |
| 42 | + @PostMapping | |
| 43 | + @ResponseStatus(HttpStatus.CREATED) | |
| 44 | + TripView create(@AuthenticationPrincipal AuthenticatedUser user, | |
| 45 | + @Valid @RequestBody SaveTripRequest request) { | |
| 46 | + return trips.create(user.id(), request); | |
| 47 | + } | |
| 48 | + | |
| 49 | + @PutMapping("/{tripId}") | |
| 50 | + TripView update(@AuthenticationPrincipal AuthenticatedUser user, | |
| 51 | + @PathVariable UUID tripId, | |
| 52 | + @Valid @RequestBody SaveTripRequest request) { | |
| 53 | + return trips.update(user.id(), tripId, request); | |
| 54 | + } | |
| 55 | + | |
| 56 | + @DeleteMapping("/{tripId}") | |
| 57 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 58 | + void delete(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable UUID tripId) { | |
| 59 | + trips.delete(user.id(), tripId); | |
| 60 | + } | |
| 61 | +} |
added backend/src/main/java/com/tasteprint/journey/TripMissionItem.java +43 −0
| @@ -0,0 +1,43 @@ | ||
| 1 | +package com.tasteprint.journey; | |
| 2 | + | |
| 3 | +import java.util.UUID; | |
| 4 | + | |
| 5 | +import jakarta.persistence.Column; | |
| 6 | +import jakarta.persistence.Entity; | |
| 7 | +import jakarta.persistence.Id; | |
| 8 | +import jakarta.persistence.Table; | |
| 9 | + | |
| 10 | +@Entity | |
| 11 | +@Table(name = "trip_mission_item") | |
| 12 | +class TripMissionItem { | |
| 13 | + | |
| 14 | + @Id | |
| 15 | + private UUID id; | |
| 16 | + | |
| 17 | + @Column(name = "trip_id", nullable = false) | |
| 18 | + private UUID tripId; | |
| 19 | + | |
| 20 | + @Column(name = "dish_slug", nullable = false, length = 120) | |
| 21 | + private String dishSlug; | |
| 22 | + | |
| 23 | + @Column(nullable = false) | |
| 24 | + private short position; | |
| 25 | + | |
| 26 | + protected TripMissionItem() { | |
| 27 | + } | |
| 28 | + | |
| 29 | + TripMissionItem(UUID id, UUID tripId, String dishSlug, int position) { | |
| 30 | + this.id = id; | |
| 31 | + this.tripId = tripId; | |
| 32 | + this.dishSlug = dishSlug; | |
| 33 | + this.position = (short) position; | |
| 34 | + } | |
| 35 | + | |
| 36 | + String dishSlug() { | |
| 37 | + return dishSlug; | |
| 38 | + } | |
| 39 | + | |
| 40 | + int position() { | |
| 41 | + return position; | |
| 42 | + } | |
| 43 | +} |
added backend/src/main/java/com/tasteprint/journey/TripMissionItemRepository.java +11 −0
| @@ -0,0 +1,11 @@ | ||
| 1 | +package com.tasteprint.journey; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | +import java.util.UUID; | |
| 5 | + | |
| 6 | +import org.springframework.data.jpa.repository.JpaRepository; | |
| 7 | + | |
| 8 | +interface TripMissionItemRepository extends JpaRepository<TripMissionItem, UUID> { | |
| 9 | + | |
| 10 | + List<TripMissionItem> findByTripIdOrderByPositionAsc(UUID tripId); | |
| 11 | +} |
added backend/src/main/java/com/tasteprint/journey/TripRepository.java +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +package com.tasteprint.journey; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | +import java.util.Optional; | |
| 5 | +import java.util.UUID; | |
| 6 | + | |
| 7 | +import org.springframework.data.jpa.repository.JpaRepository; | |
| 8 | + | |
| 9 | +interface TripRepository extends JpaRepository<Trip, UUID> { | |
| 10 | + | |
| 11 | + Optional<Trip> findByIdAndUserId(UUID id, UUID userId); | |
| 12 | + | |
| 13 | + List<Trip> findByUserIdOrderByStartsOnAsc(UUID userId); | |
| 14 | + | |
| 15 | + boolean existsByUserId(UUID userId); | |
| 16 | +} |
added backend/src/main/java/com/tasteprint/journey/TripService.java +184 −0
| @@ -0,0 +1,184 @@ | ||
| 1 | +package com.tasteprint.journey; | |
| 2 | + | |
| 3 | +import java.time.Clock; | |
| 4 | +import java.time.LocalDate; | |
| 5 | +import java.util.ArrayList; | |
| 6 | +import java.util.Comparator; | |
| 7 | +import java.util.HashSet; | |
| 8 | +import java.util.List; | |
| 9 | +import java.util.Locale; | |
| 10 | +import java.util.Optional; | |
| 11 | +import java.util.Set; | |
| 12 | +import java.util.UUID; | |
| 13 | + | |
| 14 | +import org.springframework.stereotype.Service; | |
| 15 | +import org.springframework.transaction.annotation.Transactional; | |
| 16 | + | |
| 17 | +import com.tasteprint.catalog.CatalogService; | |
| 18 | +import com.tasteprint.catalog.DestinationDetails; | |
| 19 | +import com.tasteprint.catalog.DishCategory; | |
| 20 | +import com.tasteprint.catalog.DishView; | |
| 21 | +import com.tasteprint.shared.NotFoundException; | |
| 22 | +import com.tasteprint.tasting.TastingService; | |
| 23 | + | |
| 24 | +@Service | |
| 25 | +public class TripService { | |
| 26 | + | |
| 27 | + private static final int MISSION_SIZE = 5; | |
| 28 | + | |
| 29 | + private final TripRepository trips; | |
| 30 | + private final TripMissionItemRepository missionItems; | |
| 31 | + private final CatalogService catalog; | |
| 32 | + private final TastingService tastings; | |
| 33 | + private final Clock clock; | |
| 34 | + | |
| 35 | + TripService(TripRepository trips, TripMissionItemRepository missionItems, | |
| 36 | + CatalogService catalog, TastingService tastings, Clock clock) { | |
| 37 | + this.trips = trips; | |
| 38 | + this.missionItems = missionItems; | |
| 39 | + this.catalog = catalog; | |
| 40 | + this.tastings = tastings; | |
| 41 | + this.clock = clock; | |
| 42 | + } | |
| 43 | + | |
| 44 | + @Transactional | |
| 45 | + public TripView create(UUID userId, SaveTripRequest request) { | |
| 46 | + String destinationCode = normalizeCountryCode(request.destinationCode()); | |
| 47 | + DestinationDetails destination = catalog.destination(destinationCode); | |
| 48 | + Trip trip = trips.save(new Trip( | |
| 49 | + UUID.randomUUID(), userId, destinationCode, request, clock.instant() | |
| 50 | + )); | |
| 51 | + | |
| 52 | + List<DishView> mission = chooseMission(destination.dishes(), tastings.triedDishSlugs(userId)); | |
| 53 | + for (int index = 0; index < mission.size(); index++) { | |
| 54 | + missionItems.save(new TripMissionItem(UUID.randomUUID(), trip.id(), mission.get(index).slug(), index + 1)); | |
| 55 | + } | |
| 56 | + return view(trip); | |
| 57 | + } | |
| 58 | + | |
| 59 | + @Transactional | |
| 60 | + public TripView update(UUID userId, UUID tripId, SaveTripRequest request) { | |
| 61 | + Trip trip = requiredOwned(tripId, userId); | |
| 62 | + String requestedCode = normalizeCountryCode(request.destinationCode()); | |
| 63 | + if (!trip.destinationCode().equals(requestedCode)) { | |
| 64 | + throw new IllegalArgumentException("Create a new trip to change the destination."); | |
| 65 | + } | |
| 66 | + trip.update(request, clock.instant()); | |
| 67 | + return view(trip); | |
| 68 | + } | |
| 69 | + | |
| 70 | + @Transactional | |
| 71 | + public void delete(UUID userId, UUID tripId) { | |
| 72 | + trips.delete(requiredOwned(tripId, userId)); | |
| 73 | + } | |
| 74 | + | |
| 75 | + @Transactional(readOnly = true) | |
| 76 | + public List<TripView> list(UUID userId) { | |
| 77 | + return trips.findByUserIdOrderByStartsOnAsc(userId).stream() | |
| 78 | + .map(this::view) | |
| 79 | + .sorted(Comparator.comparing((TripView trip) -> statusOrder(trip.status())) | |
| 80 | + .thenComparing(TripView::startsOn)) | |
| 81 | + .toList(); | |
| 82 | + } | |
| 83 | + | |
| 84 | + @Transactional(readOnly = true) | |
| 85 | + public TripView get(UUID userId, UUID tripId) { | |
| 86 | + return view(requiredOwned(tripId, userId)); | |
| 87 | + } | |
| 88 | + | |
| 89 | + @Transactional(readOnly = true) | |
| 90 | + public Optional<TripView> next(UUID userId) { | |
| 91 | + return list(userId).stream() | |
| 92 | + .filter(trip -> trip.status() != TripStatus.COMPLETED) | |
| 93 | + .findFirst(); | |
| 94 | + } | |
| 95 | + | |
| 96 | + @Transactional(readOnly = true) | |
| 97 | + public boolean hasAny(UUID userId) { | |
| 98 | + return trips.existsByUserId(userId); | |
| 99 | + } | |
| 100 | + | |
| 101 | + private TripView view(Trip trip) { | |
| 102 | + DestinationDetails destination = catalog.destination(trip.destinationCode()); | |
| 103 | + Set<String> completedDuringTrip = tastings.triedDishSlugsDuring( | |
| 104 | + trip.userId(), trip.destinationCode(), trip.startsOn(), trip.endsOn() | |
| 105 | + ); | |
| 106 | + List<MissionItemView> mission = missionItems.findByTripIdOrderByPositionAsc(trip.id()).stream() | |
| 107 | + .map(item -> new MissionItemView( | |
| 108 | + item.position(), catalog.dish(item.dishSlug()), completedDuringTrip.contains(item.dishSlug()) | |
| 109 | + )) | |
| 110 | + .toList(); | |
| 111 | + int coverage = weightedCoverage(destination.dishes(), completedDuringTrip); | |
| 112 | + int completed = (int) mission.stream().filter(MissionItemView::completed).count(); | |
| 113 | + return new TripView( | |
| 114 | + trip.id(), destination.destination(), trip.city(), trip.startsOn(), trip.endsOn(), | |
| 115 | + status(trip), coverage, completed, mission | |
| 116 | + ); | |
| 117 | + } | |
| 118 | + | |
| 119 | + private List<DishView> chooseMission(List<DishView> dishes, Set<String> alreadyTried) { | |
| 120 | + List<DishView> sorted = dishes.stream() | |
| 121 | + .sorted(Comparator | |
| 122 | + .comparing((DishView dish) -> alreadyTried.contains(dish.slug())) | |
| 123 | + .thenComparing(DishView::importance, Comparator.reverseOrder())) | |
| 124 | + .toList(); | |
| 125 | + List<DishView> selected = new ArrayList<>(); | |
| 126 | + Set<DishCategory> usedCategories = new HashSet<>(); | |
| 127 | + for (DishView dish : sorted) { | |
| 128 | + if (usedCategories.add(dish.category())) { | |
| 129 | + selected.add(dish); | |
| 130 | + } | |
| 131 | + if (selected.size() == MISSION_SIZE) { | |
| 132 | + break; | |
| 133 | + } | |
| 134 | + } | |
| 135 | + if (selected.size() < MISSION_SIZE) { | |
| 136 | + for (DishView dish : sorted) { | |
| 137 | + if (!selected.contains(dish)) { | |
| 138 | + selected.add(dish); | |
| 139 | + } | |
| 140 | + if (selected.size() == MISSION_SIZE) { | |
| 141 | + break; | |
| 142 | + } | |
| 143 | + } | |
| 144 | + } | |
| 145 | + return selected; | |
| 146 | + } | |
| 147 | + | |
| 148 | + private int weightedCoverage(List<DishView> dishes, Set<String> completed) { | |
| 149 | + int totalWeight = dishes.stream().mapToInt(DishView::importance).sum(); | |
| 150 | + int completedWeight = dishes.stream() | |
| 151 | + .filter(dish -> completed.contains(dish.slug())) | |
| 152 | + .mapToInt(DishView::importance) | |
| 153 | + .sum(); | |
| 154 | + return totalWeight == 0 ? 0 : (int) Math.round(completedWeight * 100.0 / totalWeight); | |
| 155 | + } | |
| 156 | + | |
| 157 | + private TripStatus status(Trip trip) { | |
| 158 | + LocalDate today = LocalDate.now(clock); | |
| 159 | + if (today.isBefore(trip.startsOn())) { | |
| 160 | + return TripStatus.UPCOMING; | |
| 161 | + } | |
| 162 | + if (today.isAfter(trip.endsOn())) { | |
| 163 | + return TripStatus.COMPLETED; | |
| 164 | + } | |
| 165 | + return TripStatus.ACTIVE; | |
| 166 | + } | |
| 167 | + | |
| 168 | + private int statusOrder(TripStatus status) { | |
| 169 | + return switch (status) { | |
| 170 | + case ACTIVE -> 0; | |
| 171 | + case UPCOMING -> 1; | |
| 172 | + case COMPLETED -> 2; | |
| 173 | + }; | |
| 174 | + } | |
| 175 | + | |
| 176 | + private Trip requiredOwned(UUID tripId, UUID userId) { | |
| 177 | + return trips.findByIdAndUserId(tripId, userId) | |
| 178 | + .orElseThrow(() -> new NotFoundException("Trip was not found.")); | |
| 179 | + } | |
| 180 | + | |
| 181 | + private String normalizeCountryCode(String countryCode) { | |
| 182 | + return countryCode.trim().toUpperCase(Locale.ROOT); | |
| 183 | + } | |
| 184 | +} |
added backend/src/main/java/com/tasteprint/journey/TripStatus.java +7 −0
| @@ -0,0 +1,7 @@ | ||
| 1 | +package com.tasteprint.journey; | |
| 2 | + | |
| 3 | +public enum TripStatus { | |
| 4 | + UPCOMING, | |
| 5 | + ACTIVE, | |
| 6 | + COMPLETED | |
| 7 | +} |
added backend/src/main/java/com/tasteprint/journey/TripView.java +20 −0
| @@ -0,0 +1,20 @@ | ||
| 1 | +package com.tasteprint.journey; | |
| 2 | + | |
| 3 | +import java.time.LocalDate; | |
| 4 | +import java.util.List; | |
| 5 | +import java.util.UUID; | |
| 6 | + | |
| 7 | +import com.tasteprint.catalog.DestinationSummary; | |
| 8 | + | |
| 9 | +public record TripView( | |
| 10 | + UUID id, | |
| 11 | + DestinationSummary destination, | |
| 12 | + String city, | |
| 13 | + LocalDate startsOn, | |
| 14 | + LocalDate endsOn, | |
| 15 | + TripStatus status, | |
| 16 | + int culinaryCoverage, | |
| 17 | + int missionCompleted, | |
| 18 | + List<MissionItemView> mission | |
| 19 | +) { | |
| 20 | +} |
added backend/src/main/java/com/tasteprint/journey/package-info.java +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +@org.springframework.modulith.ApplicationModule( | |
| 2 | + displayName = "Trips and food missions", | |
| 3 | + allowedDependencies = {"account", "catalog", "tasting", "shared"} | |
| 4 | +) | |
| 5 | +package com.tasteprint.journey; |
added backend/src/main/java/com/tasteprint/media/LocalMediaStorage.java +147 −0
| @@ -0,0 +1,147 @@ | ||
| 1 | +package com.tasteprint.media; | |
| 2 | + | |
| 3 | +import java.io.IOException; | |
| 4 | +import java.nio.file.Files; | |
| 5 | +import java.nio.file.Path; | |
| 6 | +import java.time.Clock; | |
| 7 | +import java.util.Map; | |
| 8 | +import java.util.UUID; | |
| 9 | + | |
| 10 | +import org.springframework.beans.factory.annotation.Value; | |
| 11 | +import org.springframework.stereotype.Service; | |
| 12 | +import org.springframework.transaction.annotation.Transactional; | |
| 13 | +import org.springframework.web.multipart.MultipartFile; | |
| 14 | + | |
| 15 | +import com.tasteprint.shared.ForbiddenException; | |
| 16 | + | |
| 17 | +@Service | |
| 18 | +class LocalMediaStorage implements MediaStorage { | |
| 19 | + | |
| 20 | + private static final long MAX_BYTES = 6L * 1024 * 1024; | |
| 21 | + private static final Map<String, String> EXTENSIONS = Map.of( | |
| 22 | + "image/jpeg", ".jpg", | |
| 23 | + "image/png", ".png", | |
| 24 | + "image/webp", ".webp" | |
| 25 | + ); | |
| 26 | + | |
| 27 | + private final Path mediaDirectory; | |
| 28 | + private final MediaAssetRepository assets; | |
| 29 | + private final Clock clock; | |
| 30 | + | |
| 31 | + LocalMediaStorage(@Value("${app.media-directory}") String mediaDirectory, | |
| 32 | + MediaAssetRepository assets, Clock clock) { | |
| 33 | + this.mediaDirectory = Path.of(mediaDirectory).toAbsolutePath().normalize(); | |
| 34 | + this.assets = assets; | |
| 35 | + this.clock = clock; | |
| 36 | + } | |
| 37 | + | |
| 38 | + @Override | |
| 39 | + @Transactional | |
| 40 | + public MediaUpload store(UUID ownerId, MultipartFile file) { | |
| 41 | + if (file == null || file.isEmpty()) { | |
| 42 | + throw new IllegalArgumentException("Choose a photo to upload."); | |
| 43 | + } | |
| 44 | + if (file.getSize() > MAX_BYTES) { | |
| 45 | + throw new IllegalArgumentException("Photo must be smaller than 6 MB."); | |
| 46 | + } | |
| 47 | + String contentType = file.getContentType(); | |
| 48 | + String extension = EXTENSIONS.get(contentType); | |
| 49 | + if (extension == null) { | |
| 50 | + throw new IllegalArgumentException("Photo must be JPEG, PNG, or WebP."); | |
| 51 | + } | |
| 52 | + | |
| 53 | + try { | |
| 54 | + byte[] bytes = file.getBytes(); | |
| 55 | + if (!matchesSignature(contentType, bytes)) { | |
| 56 | + throw new IllegalArgumentException("The uploaded file is not a valid image."); | |
| 57 | + } | |
| 58 | + Files.createDirectories(mediaDirectory); | |
| 59 | + UUID assetId = UUID.randomUUID(); | |
| 60 | + String filename = assetId + extension; | |
| 61 | + Path target = mediaDirectory.resolve(filename).normalize(); | |
| 62 | + if (!target.getParent().equals(mediaDirectory)) { | |
| 63 | + throw new IllegalStateException("Invalid media path."); | |
| 64 | + } | |
| 65 | + Files.write(target, bytes); | |
| 66 | + try { | |
| 67 | + assets.save(new MediaAsset(assetId, ownerId, filename, contentType, bytes.length, clock.instant())); | |
| 68 | + } catch (RuntimeException exception) { | |
| 69 | + Files.deleteIfExists(target); | |
| 70 | + throw exception; | |
| 71 | + } | |
| 72 | + return new MediaUpload("/uploads/" + filename, contentType, bytes.length); | |
| 73 | + } catch (IOException exception) { | |
| 74 | + throw new IllegalStateException("Photo could not be stored.", exception); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + @Override | |
| 79 | + @Transactional(readOnly = true) | |
| 80 | + public void requireUsableBy(UUID ownerId, String url) { | |
| 81 | + String filename = localFilename(url); | |
| 82 | + if (filename != null && !assets.existsByFilenameAndOwnerId(filename, ownerId)) { | |
| 83 | + throw new ForbiddenException("That uploaded photo does not belong to your account."); | |
| 84 | + } | |
| 85 | + } | |
| 86 | + | |
| 87 | + @Override | |
| 88 | + @Transactional | |
| 89 | + public void deleteOwned(UUID ownerId, String url) { | |
| 90 | + String filename = localFilename(url); | |
| 91 | + if (filename == null) { | |
| 92 | + return; | |
| 93 | + } | |
| 94 | + assets.findByFilename(filename) | |
| 95 | + .filter(asset -> asset.ownerId().equals(ownerId)) | |
| 96 | + .ifPresent(asset -> { | |
| 97 | + deleteFile(asset.filename()); | |
| 98 | + assets.delete(asset); | |
| 99 | + }); | |
| 100 | + } | |
| 101 | + | |
| 102 | + @Override | |
| 103 | + @Transactional | |
| 104 | + public void deleteAllOwned(UUID ownerId) { | |
| 105 | + for (MediaAsset asset : assets.findByOwnerId(ownerId)) { | |
| 106 | + deleteFile(asset.filename()); | |
| 107 | + assets.delete(asset); | |
| 108 | + } | |
| 109 | + } | |
| 110 | + | |
| 111 | + private String localFilename(String url) { | |
| 112 | + if (url == null || url.isBlank() || !url.startsWith("/uploads/")) { | |
| 113 | + return null; | |
| 114 | + } | |
| 115 | + return url.substring("/uploads/".length()); | |
| 116 | + } | |
| 117 | + | |
| 118 | + private void deleteFile(String filename) { | |
| 119 | + try { | |
| 120 | + Path target = mediaDirectory.resolve(filename).normalize(); | |
| 121 | + if (!target.getParent().equals(mediaDirectory)) { | |
| 122 | + throw new IllegalStateException("Invalid media path."); | |
| 123 | + } | |
| 124 | + Files.deleteIfExists(target); | |
| 125 | + } catch (IOException exception) { | |
| 126 | + throw new IllegalStateException("Photo could not be deleted.", exception); | |
| 127 | + } | |
| 128 | + } | |
| 129 | + | |
| 130 | + private boolean matchesSignature(String contentType, byte[] bytes) { | |
| 131 | + return switch (contentType) { | |
| 132 | + case "image/jpeg" -> bytes.length >= 3 | |
| 133 | + && unsigned(bytes[0]) == 0xFF && unsigned(bytes[1]) == 0xD8 && unsigned(bytes[2]) == 0xFF; | |
| 134 | + case "image/png" -> bytes.length >= 8 | |
| 135 | + && unsigned(bytes[0]) == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 | |
| 136 | + && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A; | |
| 137 | + case "image/webp" -> bytes.length >= 12 | |
| 138 | + && bytes[0] == 'R' && bytes[1] == 'I' && bytes[2] == 'F' && bytes[3] == 'F' | |
| 139 | + && bytes[8] == 'W' && bytes[9] == 'E' && bytes[10] == 'B' && bytes[11] == 'P'; | |
| 140 | + default -> false; | |
| 141 | + }; | |
| 142 | + } | |
| 143 | + | |
| 144 | + private int unsigned(byte value) { | |
| 145 | + return value & 0xFF; | |
| 146 | + } | |
| 147 | +} |
added backend/src/main/java/com/tasteprint/media/MediaAccountDeletionListener.java +21 −0
| @@ -0,0 +1,21 @@ | ||
| 1 | +package com.tasteprint.media; | |
| 2 | + | |
| 3 | +import org.springframework.context.event.EventListener; | |
| 4 | +import org.springframework.stereotype.Component; | |
| 5 | + | |
| 6 | +import com.tasteprint.account.AccountDeletionRequested; | |
| 7 | + | |
| 8 | +@Component | |
| 9 | +class MediaAccountDeletionListener { | |
| 10 | + | |
| 11 | + private final MediaStorage mediaStorage; | |
| 12 | + | |
| 13 | + MediaAccountDeletionListener(MediaStorage mediaStorage) { | |
| 14 | + this.mediaStorage = mediaStorage; | |
| 15 | + } | |
| 16 | + | |
| 17 | + @EventListener | |
| 18 | + void removeOwnedMedia(AccountDeletionRequested event) { | |
| 19 | + mediaStorage.deleteAllOwned(event.accountId()); | |
| 20 | + } | |
| 21 | +} |
added backend/src/main/java/com/tasteprint/media/MediaAsset.java +52 −0
| @@ -0,0 +1,52 @@ | ||
| 1 | +package com.tasteprint.media; | |
| 2 | + | |
| 3 | +import java.time.Instant; | |
| 4 | +import java.util.UUID; | |
| 5 | + | |
| 6 | +import jakarta.persistence.Column; | |
| 7 | +import jakarta.persistence.Entity; | |
| 8 | +import jakarta.persistence.Id; | |
| 9 | +import jakarta.persistence.Table; | |
| 10 | + | |
| 11 | +@Entity | |
| 12 | +@Table(name = "media_asset") | |
| 13 | +class MediaAsset { | |
| 14 | + | |
| 15 | + @Id | |
| 16 | + private UUID id; | |
| 17 | + | |
| 18 | + @Column(name = "owner_id", nullable = false) | |
| 19 | + private UUID ownerId; | |
| 20 | + | |
| 21 | + @Column(nullable = false, length = 80, unique = true) | |
| 22 | + private String filename; | |
| 23 | + | |
| 24 | + @Column(name = "content_type", nullable = false, length = 40) | |
| 25 | + private String contentType; | |
| 26 | + | |
| 27 | + @Column(name = "size_bytes", nullable = false) | |
| 28 | + private long size; | |
| 29 | + | |
| 30 | + @Column(name = "created_at", nullable = false) | |
| 31 | + private Instant createdAt; | |
| 32 | + | |
| 33 | + protected MediaAsset() { | |
| 34 | + } | |
| 35 | + | |
| 36 | + MediaAsset(UUID id, UUID ownerId, String filename, String contentType, long size, Instant createdAt) { | |
| 37 | + this.id = id; | |
| 38 | + this.ownerId = ownerId; | |
| 39 | + this.filename = filename; | |
| 40 | + this.contentType = contentType; | |
| 41 | + this.size = size; | |
| 42 | + this.createdAt = createdAt; | |
| 43 | + } | |
| 44 | + | |
| 45 | + UUID ownerId() { | |
| 46 | + return ownerId; | |
| 47 | + } | |
| 48 | + | |
| 49 | + String filename() { | |
| 50 | + return filename; | |
| 51 | + } | |
| 52 | +} |
added backend/src/main/java/com/tasteprint/media/MediaAssetRepository.java +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +package com.tasteprint.media; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | +import java.util.Optional; | |
| 5 | +import java.util.UUID; | |
| 6 | + | |
| 7 | +import org.springframework.data.jpa.repository.JpaRepository; | |
| 8 | + | |
| 9 | +interface MediaAssetRepository extends JpaRepository<MediaAsset, UUID> { | |
| 10 | + | |
| 11 | + Optional<MediaAsset> findByFilename(String filename); | |
| 12 | + | |
| 13 | + boolean existsByFilenameAndOwnerId(String filename, UUID ownerId); | |
| 14 | + | |
| 15 | + List<MediaAsset> findByOwnerId(UUID ownerId); | |
| 16 | +} |
added backend/src/main/java/com/tasteprint/media/MediaController.java +30 −0
| @@ -0,0 +1,30 @@ | ||
| 1 | +package com.tasteprint.media; | |
| 2 | + | |
| 3 | +import org.springframework.http.HttpStatus; | |
| 4 | +import org.springframework.security.core.annotation.AuthenticationPrincipal; | |
| 5 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 6 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 7 | +import org.springframework.web.bind.annotation.RequestPart; | |
| 8 | +import org.springframework.web.bind.annotation.ResponseStatus; | |
| 9 | +import org.springframework.web.bind.annotation.RestController; | |
| 10 | +import org.springframework.web.multipart.MultipartFile; | |
| 11 | + | |
| 12 | +import com.tasteprint.account.AuthenticatedUser; | |
| 13 | + | |
| 14 | +@RestController | |
| 15 | +@RequestMapping("/api/v1/media") | |
| 16 | +class MediaController { | |
| 17 | + | |
| 18 | + private final MediaStorage mediaStorage; | |
| 19 | + | |
| 20 | + MediaController(MediaStorage mediaStorage) { | |
| 21 | + this.mediaStorage = mediaStorage; | |
| 22 | + } | |
| 23 | + | |
| 24 | + @PostMapping | |
| 25 | + @ResponseStatus(HttpStatus.CREATED) | |
| 26 | + MediaUpload upload(@AuthenticationPrincipal AuthenticatedUser user, | |
| 27 | + @RequestPart("file") MultipartFile file) { | |
| 28 | + return mediaStorage.store(user.id(), file); | |
| 29 | + } | |
| 30 | +} |
added backend/src/main/java/com/tasteprint/media/MediaStorage.java +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +package com.tasteprint.media; | |
| 2 | + | |
| 3 | +import java.util.UUID; | |
| 4 | + | |
| 5 | +import org.springframework.web.multipart.MultipartFile; | |
| 6 | + | |
| 7 | +public interface MediaStorage { | |
| 8 | + | |
| 9 | + MediaUpload store(UUID ownerId, MultipartFile file); | |
| 10 | + | |
| 11 | + void requireUsableBy(UUID ownerId, String url); | |
| 12 | + | |
| 13 | + void deleteOwned(UUID ownerId, String url); | |
| 14 | + | |
| 15 | + void deleteAllOwned(UUID ownerId); | |
| 16 | +} |
added backend/src/main/java/com/tasteprint/media/MediaUpload.java +4 −0
| @@ -0,0 +1,4 @@ | ||
| 1 | +package com.tasteprint.media; | |
| 2 | + | |
| 3 | +public record MediaUpload(String url, String contentType, long size) { | |
| 4 | +} |
added backend/src/main/java/com/tasteprint/media/MediaWebConfiguration.java +24 −0
| @@ -0,0 +1,24 @@ | ||
| 1 | +package com.tasteprint.media; | |
| 2 | + | |
| 3 | +import java.nio.file.Path; | |
| 4 | + | |
| 5 | +import org.springframework.beans.factory.annotation.Value; | |
| 6 | +import org.springframework.context.annotation.Configuration; | |
| 7 | +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; | |
| 8 | +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; | |
| 9 | + | |
| 10 | +@Configuration | |
| 11 | +class MediaWebConfiguration implements WebMvcConfigurer { | |
| 12 | + | |
| 13 | + private final Path mediaDirectory; | |
| 14 | + | |
| 15 | + MediaWebConfiguration(@Value("${app.media-directory}") String mediaDirectory) { | |
| 16 | + this.mediaDirectory = Path.of(mediaDirectory).toAbsolutePath().normalize(); | |
| 17 | + } | |
| 18 | + | |
| 19 | + @Override | |
| 20 | + public void addResourceHandlers(ResourceHandlerRegistry registry) { | |
| 21 | + registry.addResourceHandler("/uploads/**") | |
| 22 | + .addResourceLocations(mediaDirectory.toUri().toString()); | |
| 23 | + } | |
| 24 | +} |
added backend/src/main/java/com/tasteprint/media/package-info.java +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +@org.springframework.modulith.ApplicationModule( | |
| 2 | + displayName = "Media storage", | |
| 3 | + allowedDependencies = {"account", "shared"} | |
| 4 | +) | |
| 5 | +package com.tasteprint.media; |
added backend/src/main/java/com/tasteprint/progress/DashboardView.java +11 −0
| @@ -0,0 +1,11 @@ | ||
| 1 | +package com.tasteprint.progress; | |
| 2 | + | |
| 3 | +import com.tasteprint.account.AccountView; | |
| 4 | +import com.tasteprint.journey.TripView; | |
| 5 | + | |
| 6 | +public record DashboardView( | |
| 7 | + AccountView user, | |
| 8 | + TasteSnapshot tasteprint, | |
| 9 | + TripView nextTrip | |
| 10 | +) { | |
| 11 | +} |
added backend/src/main/java/com/tasteprint/progress/DestinationProgress.java +11 −0
| @@ -0,0 +1,11 @@ | ||
| 1 | +package com.tasteprint.progress; | |
| 2 | + | |
| 3 | +import com.tasteprint.catalog.DestinationSummary; | |
| 4 | + | |
| 5 | +public record DestinationProgress( | |
| 6 | + DestinationSummary destination, | |
| 7 | + int coverage, | |
| 8 | + int triedDishes, | |
| 9 | + int totalDishes | |
| 10 | +) { | |
| 11 | +} |
added backend/src/main/java/com/tasteprint/progress/DestinationProgressDetails.java +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +package com.tasteprint.progress; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +public record DestinationProgressDetails( | |
| 6 | + DestinationProgress progress, | |
| 7 | + List<DishProgress> dishes | |
| 8 | +) { | |
| 9 | +} |
added backend/src/main/java/com/tasteprint/progress/DishProgress.java +6 −0
| @@ -0,0 +1,6 @@ | ||
| 1 | +package com.tasteprint.progress; | |
| 2 | + | |
| 3 | +import com.tasteprint.catalog.DishView; | |
| 4 | + | |
| 5 | +public record DishProgress(DishView dish, boolean tried) { | |
| 6 | +} |
added backend/src/main/java/com/tasteprint/progress/ProgressController.java +31 −0
| @@ -0,0 +1,31 @@ | ||
| 1 | +package com.tasteprint.progress; | |
| 2 | + | |
| 3 | +import org.springframework.security.core.annotation.AuthenticationPrincipal; | |
| 4 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 5 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 6 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 7 | +import org.springframework.web.bind.annotation.RestController; | |
| 8 | + | |
| 9 | +import com.tasteprint.account.AuthenticatedUser; | |
| 10 | + | |
| 11 | +@RestController | |
| 12 | +@RequestMapping("/api/v1/progress") | |
| 13 | +class ProgressController { | |
| 14 | + | |
| 15 | + private final ProgressService progress; | |
| 16 | + | |
| 17 | + ProgressController(ProgressService progress) { | |
| 18 | + this.progress = progress; | |
| 19 | + } | |
| 20 | + | |
| 21 | + @GetMapping("/dashboard") | |
| 22 | + DashboardView dashboard(@AuthenticationPrincipal AuthenticatedUser user) { | |
| 23 | + return progress.dashboard(user.id()); | |
| 24 | + } | |
| 25 | + | |
| 26 | + @GetMapping("/destinations/{code}") | |
| 27 | + DestinationProgressDetails destination(@AuthenticationPrincipal AuthenticatedUser user, | |
| 28 | + @PathVariable String code) { | |
| 29 | + return progress.destination(user.id(), code); | |
| 30 | + } | |
| 31 | +} |
added backend/src/main/java/com/tasteprint/progress/ProgressService.java +176 −0
| @@ -0,0 +1,176 @@ | ||
| 1 | +package com.tasteprint.progress; | |
| 2 | + | |
| 3 | +import java.util.ArrayList; | |
| 4 | +import java.util.Comparator; | |
| 5 | +import java.util.HashMap; | |
| 6 | +import java.util.HashSet; | |
| 7 | +import java.util.LinkedHashSet; | |
| 8 | +import java.util.List; | |
| 9 | +import java.util.Map; | |
| 10 | +import java.util.Optional; | |
| 11 | +import java.util.Set; | |
| 12 | +import java.util.UUID; | |
| 13 | +import java.util.function.Function; | |
| 14 | +import java.util.stream.Collectors; | |
| 15 | + | |
| 16 | +import org.springframework.stereotype.Service; | |
| 17 | +import org.springframework.transaction.annotation.Transactional; | |
| 18 | + | |
| 19 | +import com.tasteprint.account.AccountService; | |
| 20 | +import com.tasteprint.catalog.CatalogService; | |
| 21 | +import com.tasteprint.catalog.DestinationDetails; | |
| 22 | +import com.tasteprint.catalog.DestinationSummary; | |
| 23 | +import com.tasteprint.catalog.DishView; | |
| 24 | +import com.tasteprint.journey.TripService; | |
| 25 | +import com.tasteprint.journey.TripView; | |
| 26 | +import com.tasteprint.tasting.TastingService; | |
| 27 | + | |
| 28 | +@Service | |
| 29 | +public class ProgressService { | |
| 30 | + | |
| 31 | + private static final int MISSING_LIMIT = 6; | |
| 32 | + | |
| 33 | + private final AccountService accounts; | |
| 34 | + private final CatalogService catalog; | |
| 35 | + private final TastingService tastings; | |
| 36 | + private final TripService trips; | |
| 37 | + | |
| 38 | + ProgressService(AccountService accounts, CatalogService catalog, | |
| 39 | + TastingService tastings, TripService trips) { | |
| 40 | + this.accounts = accounts; | |
| 41 | + this.catalog = catalog; | |
| 42 | + this.tastings = tastings; | |
| 43 | + this.trips = trips; | |
| 44 | + } | |
| 45 | + | |
| 46 | + @Transactional(readOnly = true) | |
| 47 | + public DashboardView dashboard(UUID userId) { | |
| 48 | + Optional<TripView> nextTrip = trips.next(userId); | |
| 49 | + return new DashboardView( | |
| 50 | + accounts.get(userId), snapshot(userId, nextTrip.map(trip -> trip.destination().code()).orElse(null)), | |
| 51 | + nextTrip.orElse(null) | |
| 52 | + ); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Transactional(readOnly = true) | |
| 56 | + public TasteSnapshot snapshot(UUID userId) { | |
| 57 | + return snapshot(userId, null); | |
| 58 | + } | |
| 59 | + | |
| 60 | + @Transactional(readOnly = true) | |
| 61 | + public DestinationProgressDetails destination(UUID userId, String code) { | |
| 62 | + DestinationDetails details = catalog.destination(code); | |
| 63 | + Set<String> tried = tastings.triedDishSlugs(userId); | |
| 64 | + DestinationProgress progress = progress(details.destination(), details.dishes(), tried); | |
| 65 | + return new DestinationProgressDetails( | |
| 66 | + progress, | |
| 67 | + details.dishes().stream() | |
| 68 | + .map(dish -> new DishProgress(dish, tried.contains(dish.slug()))) | |
| 69 | + .toList() | |
| 70 | + ); | |
| 71 | + } | |
| 72 | + | |
| 73 | + private TasteSnapshot snapshot(UUID userId, String priorityDestination) { | |
| 74 | + Set<String> tried = tastings.triedDishSlugs(userId); | |
| 75 | + List<DishView> allDishes = catalog.allDishes(); | |
| 76 | + Map<String, List<DishView>> dishesByDestination = allDishes.stream() | |
| 77 | + .collect(Collectors.groupingBy(DishView::destinationCode)); | |
| 78 | + | |
| 79 | + List<DestinationProgress> destinationProgress = catalog.destinations().stream() | |
| 80 | + .map(destination -> progress( | |
| 81 | + destination, | |
| 82 | + dishesByDestination.getOrDefault(destination.code(), List.of()), | |
| 83 | + tried | |
| 84 | + )) | |
| 85 | + .toList(); | |
| 86 | + | |
| 87 | + Set<String> tastedDestinations = allDishes.stream() | |
| 88 | + .filter(dish -> tried.contains(dish.slug())) | |
| 89 | + .map(DishView::destinationCode) | |
| 90 | + .collect(Collectors.toSet()); | |
| 91 | + Map<String, DestinationSummary> destinationsByCode = catalog.destinations().stream() | |
| 92 | + .collect(Collectors.toMap(DestinationSummary::code, Function.identity())); | |
| 93 | + int regions = tastedDestinations.stream() | |
| 94 | + .map(destinationsByCode::get) | |
| 95 | + .filter(java.util.Objects::nonNull) | |
| 96 | + .map(DestinationSummary::regionName) | |
| 97 | + .collect(Collectors.toSet()) | |
| 98 | + .size(); | |
| 99 | + | |
| 100 | + int totalWeight = allDishes.stream().mapToInt(DishView::importance).sum(); | |
| 101 | + int triedWeight = allDishes.stream() | |
| 102 | + .filter(dish -> tried.contains(dish.slug())) | |
| 103 | + .mapToInt(DishView::importance) | |
| 104 | + .sum(); | |
| 105 | + TasteStats stats = new TasteStats( | |
| 106 | + tastings.count(userId), tried.size(), tastedDestinations.size(), regions, | |
| 107 | + percentage(triedWeight, totalWeight) | |
| 108 | + ); | |
| 109 | + | |
| 110 | + return new TasteSnapshot( | |
| 111 | + stats, | |
| 112 | + destinationProgress, | |
| 113 | + importantMissing(allDishes, tried, tastedDestinations, priorityDestination), | |
| 114 | + tastings.recent(userId) | |
| 115 | + ); | |
| 116 | + } | |
| 117 | + | |
| 118 | + private DestinationProgress progress(DestinationSummary destination, List<DishView> dishes, | |
| 119 | + Set<String> tried) { | |
| 120 | + int totalWeight = dishes.stream().mapToInt(DishView::importance).sum(); | |
| 121 | + int triedWeight = dishes.stream() | |
| 122 | + .filter(dish -> tried.contains(dish.slug())) | |
| 123 | + .mapToInt(DishView::importance) | |
| 124 | + .sum(); | |
| 125 | + int triedCount = (int) dishes.stream().filter(dish -> tried.contains(dish.slug())).count(); | |
| 126 | + return new DestinationProgress( | |
| 127 | + destination, percentage(triedWeight, totalWeight), triedCount, dishes.size() | |
| 128 | + ); | |
| 129 | + } | |
| 130 | + | |
| 131 | + private List<DishView> importantMissing(List<DishView> allDishes, Set<String> tried, | |
| 132 | + Set<String> tastedDestinations, String priorityDestination) { | |
| 133 | + List<DishView> candidates = allDishes.stream() | |
| 134 | + .filter(dish -> !tried.contains(dish.slug())) | |
| 135 | + .sorted(Comparator | |
| 136 | + .comparingInt((DishView dish) -> missingScore(dish, tastedDestinations, priorityDestination)) | |
| 137 | + .reversed() | |
| 138 | + .thenComparing(DishView::name)) | |
| 139 | + .toList(); | |
| 140 | + | |
| 141 | + List<DishView> selected = new ArrayList<>(); | |
| 142 | + Set<String> usedDestinations = new LinkedHashSet<>(); | |
| 143 | + for (DishView dish : candidates) { | |
| 144 | + if (usedDestinations.add(dish.destinationCode())) { | |
| 145 | + selected.add(dish); | |
| 146 | + } | |
| 147 | + if (selected.size() == MISSING_LIMIT) { | |
| 148 | + return selected; | |
| 149 | + } | |
| 150 | + } | |
| 151 | + for (DishView dish : candidates) { | |
| 152 | + if (!selected.contains(dish)) { | |
| 153 | + selected.add(dish); | |
| 154 | + } | |
| 155 | + if (selected.size() == MISSING_LIMIT) { | |
| 156 | + break; | |
| 157 | + } | |
| 158 | + } | |
| 159 | + return selected; | |
| 160 | + } | |
| 161 | + | |
| 162 | + private int missingScore(DishView dish, Set<String> tastedDestinations, String priorityDestination) { | |
| 163 | + int score = dish.importance() * 10; | |
| 164 | + if (tastedDestinations.contains(dish.destinationCode())) { | |
| 165 | + score += 100; | |
| 166 | + } | |
| 167 | + if (dish.destinationCode().equals(priorityDestination)) { | |
| 168 | + score += 200; | |
| 169 | + } | |
| 170 | + return score; | |
| 171 | + } | |
| 172 | + | |
| 173 | + private int percentage(int part, int whole) { | |
| 174 | + return whole == 0 ? 0 : (int) Math.round(part * 100.0 / whole); | |
| 175 | + } | |
| 176 | +} |
added backend/src/main/java/com/tasteprint/progress/TasteSnapshot.java +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +package com.tasteprint.progress; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +import com.tasteprint.catalog.DishView; | |
| 6 | +import com.tasteprint.tasting.TastingView; | |
| 7 | + | |
| 8 | +public record TasteSnapshot( | |
| 9 | + TasteStats stats, | |
| 10 | + List<DestinationProgress> destinations, | |
| 11 | + List<DishView> importantMissing, | |
| 12 | + List<TastingView> recentTastings | |
| 13 | +) { | |
| 14 | +} |
added backend/src/main/java/com/tasteprint/progress/TasteStats.java +10 −0
| @@ -0,0 +1,10 @@ | ||
| 1 | +package com.tasteprint.progress; | |
| 2 | + | |
| 3 | +public record TasteStats( | |
| 4 | + long totalTastings, | |
| 5 | + int uniqueDishes, | |
| 6 | + int countriesTasted, | |
| 7 | + int regionsTasted, | |
| 8 | + int worldCoverage | |
| 9 | +) { | |
| 10 | +} |
added backend/src/main/java/com/tasteprint/progress/package-info.java +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +@org.springframework.modulith.ApplicationModule( | |
| 2 | + displayName = "Culinary coverage", | |
| 3 | + allowedDependencies = {"account", "catalog", "journey", "tasting", "shared"} | |
| 4 | +) | |
| 5 | +package com.tasteprint.progress; |
added backend/src/main/java/com/tasteprint/shared/ApiExceptionHandler.java +71 −0
| @@ -0,0 +1,71 @@ | ||
| 1 | +package com.tasteprint.shared; | |
| 2 | + | |
| 3 | +import java.net.URI; | |
| 4 | +import java.util.LinkedHashMap; | |
| 5 | +import java.util.Map; | |
| 6 | + | |
| 7 | +import jakarta.validation.ConstraintViolationException; | |
| 8 | + | |
| 9 | +import org.springframework.dao.DataIntegrityViolationException; | |
| 10 | +import org.springframework.http.HttpStatus; | |
| 11 | +import org.springframework.http.ProblemDetail; | |
| 12 | +import org.springframework.security.core.AuthenticationException; | |
| 13 | +import org.springframework.web.bind.MethodArgumentNotValidException; | |
| 14 | +import org.springframework.web.bind.annotation.ExceptionHandler; | |
| 15 | +import org.springframework.web.bind.annotation.RestControllerAdvice; | |
| 16 | + | |
| 17 | +@RestControllerAdvice | |
| 18 | +class ApiExceptionHandler { | |
| 19 | + | |
| 20 | + @ExceptionHandler(NotFoundException.class) | |
| 21 | + ProblemDetail handleNotFound(NotFoundException exception) { | |
| 22 | + return problem(HttpStatus.NOT_FOUND, "Resource not found", exception.getMessage()); | |
| 23 | + } | |
| 24 | + | |
| 25 | + @ExceptionHandler(ConflictException.class) | |
| 26 | + ProblemDetail handleConflict(ConflictException exception) { | |
| 27 | + return problem(HttpStatus.CONFLICT, "Conflict", exception.getMessage()); | |
| 28 | + } | |
| 29 | + | |
| 30 | + @ExceptionHandler(ForbiddenException.class) | |
| 31 | + ProblemDetail handleForbidden(ForbiddenException exception) { | |
| 32 | + return problem(HttpStatus.FORBIDDEN, "Forbidden", exception.getMessage()); | |
| 33 | + } | |
| 34 | + | |
| 35 | + @ExceptionHandler(AuthenticationException.class) | |
| 36 | + ProblemDetail handleAuthentication(AuthenticationException exception) { | |
| 37 | + return problem(HttpStatus.UNAUTHORIZED, "Authentication failed", exception.getMessage()); | |
| 38 | + } | |
| 39 | + | |
| 40 | + @ExceptionHandler(MethodArgumentNotValidException.class) | |
| 41 | + ProblemDetail handleValidation(MethodArgumentNotValidException exception) { | |
| 42 | + ProblemDetail detail = problem(HttpStatus.BAD_REQUEST, "Validation failed", "Check the highlighted fields."); | |
| 43 | + Map<String, String> errors = new LinkedHashMap<>(); | |
| 44 | + exception.getBindingResult().getFieldErrors().forEach(error -> | |
| 45 | + errors.putIfAbsent(error.getField(), error.getDefaultMessage())); | |
| 46 | + detail.setProperty("errors", errors); | |
| 47 | + return detail; | |
| 48 | + } | |
| 49 | + | |
| 50 | + @ExceptionHandler(ConstraintViolationException.class) | |
| 51 | + ProblemDetail handleConstraintViolation(ConstraintViolationException exception) { | |
| 52 | + return problem(HttpStatus.BAD_REQUEST, "Validation failed", exception.getMessage()); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @ExceptionHandler(DataIntegrityViolationException.class) | |
| 56 | + ProblemDetail handleIntegrityViolation() { | |
| 57 | + return problem(HttpStatus.CONFLICT, "Conflict", "That change conflicts with existing data."); | |
| 58 | + } | |
| 59 | + | |
| 60 | + @ExceptionHandler(IllegalArgumentException.class) | |
| 61 | + ProblemDetail handleIllegalArgument(IllegalArgumentException exception) { | |
| 62 | + return problem(HttpStatus.BAD_REQUEST, "Invalid request", exception.getMessage()); | |
| 63 | + } | |
| 64 | + | |
| 65 | + private ProblemDetail problem(HttpStatus status, String title, String detail) { | |
| 66 | + ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail); | |
| 67 | + problem.setTitle(title); | |
| 68 | + problem.setType(URI.create("https://tasteprint.app/problems/" + status.value())); | |
| 69 | + return problem; | |
| 70 | + } | |
| 71 | +} |
added backend/src/main/java/com/tasteprint/shared/ClockConfiguration.java +15 −0
| @@ -0,0 +1,15 @@ | ||
| 1 | +package com.tasteprint.shared; | |
| 2 | + | |
| 3 | +import java.time.Clock; | |
| 4 | + | |
| 5 | +import org.springframework.context.annotation.Bean; | |
| 6 | +import org.springframework.context.annotation.Configuration; | |
| 7 | + | |
| 8 | +@Configuration | |
| 9 | +class ClockConfiguration { | |
| 10 | + | |
| 11 | + @Bean | |
| 12 | + Clock clock() { | |
| 13 | + return Clock.systemUTC(); | |
| 14 | + } | |
| 15 | +} |
added backend/src/main/java/com/tasteprint/shared/ConflictException.java +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +package com.tasteprint.shared; | |
| 2 | + | |
| 3 | +public class ConflictException extends RuntimeException { | |
| 4 | + | |
| 5 | + public ConflictException(String message) { | |
| 6 | + super(message); | |
| 7 | + } | |
| 8 | +} |
added backend/src/main/java/com/tasteprint/shared/ForbiddenException.java +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +package com.tasteprint.shared; | |
| 2 | + | |
| 3 | +public class ForbiddenException extends RuntimeException { | |
| 4 | + | |
| 5 | + public ForbiddenException(String message) { | |
| 6 | + super(message); | |
| 7 | + } | |
| 8 | +} |
added backend/src/main/java/com/tasteprint/shared/NotFoundException.java +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +package com.tasteprint.shared; | |
| 2 | + | |
| 3 | +public class NotFoundException extends RuntimeException { | |
| 4 | + | |
| 5 | + public NotFoundException(String message) { | |
| 6 | + super(message); | |
| 7 | + } | |
| 8 | +} |
added backend/src/main/java/com/tasteprint/shared/OpenApiConfiguration.java +26 −0
| @@ -0,0 +1,26 @@ | ||
| 1 | +package com.tasteprint.shared; | |
| 2 | + | |
| 3 | +import io.swagger.v3.oas.models.Components; | |
| 4 | +import io.swagger.v3.oas.models.OpenAPI; | |
| 5 | +import io.swagger.v3.oas.models.info.Info; | |
| 6 | +import io.swagger.v3.oas.models.security.SecurityScheme; | |
| 7 | +import org.springframework.context.annotation.Bean; | |
| 8 | +import org.springframework.context.annotation.Configuration; | |
| 9 | + | |
| 10 | +@Configuration | |
| 11 | +class OpenApiConfiguration { | |
| 12 | + | |
| 13 | + @Bean | |
| 14 | + OpenAPI tasteprintOpenApi() { | |
| 15 | + return new OpenAPI() | |
| 16 | + .info(new Info() | |
| 17 | + .title("Tasteprint API") | |
| 18 | + .version("v1") | |
| 19 | + .description("Track culinary coverage, trips, missions, and shared challenges.")) | |
| 20 | + .components(new Components().addSecuritySchemes("bearerToken", | |
| 21 | + new SecurityScheme() | |
| 22 | + .type(SecurityScheme.Type.HTTP) | |
| 23 | + .scheme("bearer") | |
| 24 | + .bearerFormat("opaque"))); | |
| 25 | + } | |
| 26 | +} |
added backend/src/main/java/com/tasteprint/shared/package-info.java +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +@org.springframework.modulith.ApplicationModule( | |
| 2 | + displayName = "Shared kernel", | |
| 3 | + type = org.springframework.modulith.ApplicationModule.Type.OPEN | |
| 4 | +) | |
| 5 | +package com.tasteprint.shared; |
added backend/src/main/java/com/tasteprint/social/AccountDeletionListener.java +21 −0
| @@ -0,0 +1,21 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import org.springframework.context.event.EventListener; | |
| 4 | +import org.springframework.stereotype.Component; | |
| 5 | + | |
| 6 | +import com.tasteprint.account.AccountDeletionRequested; | |
| 7 | + | |
| 8 | +@Component | |
| 9 | +class AccountDeletionListener { | |
| 10 | + | |
| 11 | + private final TasteChallengeRepository challenges; | |
| 12 | + | |
| 13 | + AccountDeletionListener(TasteChallengeRepository challenges) { | |
| 14 | + this.challenges = challenges; | |
| 15 | + } | |
| 16 | + | |
| 17 | + @EventListener | |
| 18 | + void removeOwnedChallenges(AccountDeletionRequested event) { | |
| 19 | + challenges.deleteByOwnerId(event.accountId()); | |
| 20 | + } | |
| 21 | +} |
added backend/src/main/java/com/tasteprint/social/ChallengeController.java +65 −0
| @@ -0,0 +1,65 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | +import java.util.UUID; | |
| 5 | + | |
| 6 | +import jakarta.validation.Valid; | |
| 7 | + | |
| 8 | +import org.springframework.http.HttpStatus; | |
| 9 | +import org.springframework.security.core.annotation.AuthenticationPrincipal; | |
| 10 | +import org.springframework.web.bind.annotation.DeleteMapping; | |
| 11 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 12 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 13 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 14 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 15 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 16 | +import org.springframework.web.bind.annotation.ResponseStatus; | |
| 17 | +import org.springframework.web.bind.annotation.RestController; | |
| 18 | + | |
| 19 | +import com.tasteprint.account.AuthenticatedUser; | |
| 20 | + | |
| 21 | +@RestController | |
| 22 | +@RequestMapping("/api/v1/challenges") | |
| 23 | +class ChallengeController { | |
| 24 | + | |
| 25 | + private final ChallengeService challenges; | |
| 26 | + | |
| 27 | + ChallengeController(ChallengeService challenges) { | |
| 28 | + this.challenges = challenges; | |
| 29 | + } | |
| 30 | + | |
| 31 | + @GetMapping | |
| 32 | + List<ChallengeView> list(@AuthenticationPrincipal AuthenticatedUser user) { | |
| 33 | + return challenges.list(user.id()); | |
| 34 | + } | |
| 35 | + | |
| 36 | + @GetMapping("/{challengeId}") | |
| 37 | + ChallengeView get(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable UUID challengeId) { | |
| 38 | + return challenges.get(user.id(), challengeId); | |
| 39 | + } | |
| 40 | + | |
| 41 | + @PostMapping | |
| 42 | + @ResponseStatus(HttpStatus.CREATED) | |
| 43 | + ChallengeView create(@AuthenticationPrincipal AuthenticatedUser user, | |
| 44 | + @Valid @RequestBody CreateChallengeRequest request) { | |
| 45 | + return challenges.create(user.id(), request); | |
| 46 | + } | |
| 47 | + | |
| 48 | + @PostMapping("/join") | |
| 49 | + ChallengeView join(@AuthenticationPrincipal AuthenticatedUser user, | |
| 50 | + @Valid @RequestBody JoinChallengeRequest request) { | |
| 51 | + return challenges.join(user.id(), request.joinCode()); | |
| 52 | + } | |
| 53 | + | |
| 54 | + @DeleteMapping("/{challengeId}/members/me") | |
| 55 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 56 | + void leave(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable UUID challengeId) { | |
| 57 | + challenges.leave(user.id(), challengeId); | |
| 58 | + } | |
| 59 | + | |
| 60 | + @DeleteMapping("/{challengeId}") | |
| 61 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 62 | + void delete(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable UUID challengeId) { | |
| 63 | + challenges.delete(user.id(), challengeId); | |
| 64 | + } | |
| 65 | +} |
added backend/src/main/java/com/tasteprint/social/ChallengeParticipant.java +44 −0
| @@ -0,0 +1,44 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import java.time.Instant; | |
| 4 | +import java.util.UUID; | |
| 5 | + | |
| 6 | +import jakarta.persistence.Column; | |
| 7 | +import jakarta.persistence.Entity; | |
| 8 | +import jakarta.persistence.Id; | |
| 9 | +import jakarta.persistence.Table; | |
| 10 | + | |
| 11 | +@Entity | |
| 12 | +@Table(name = "challenge_participant") | |
| 13 | +class ChallengeParticipant { | |
| 14 | + | |
| 15 | + @Id | |
| 16 | + private UUID id; | |
| 17 | + | |
| 18 | + @Column(name = "challenge_id", nullable = false) | |
| 19 | + private UUID challengeId; | |
| 20 | + | |
| 21 | + @Column(name = "user_id", nullable = false) | |
| 22 | + private UUID userId; | |
| 23 | + | |
| 24 | + @Column(name = "joined_at", nullable = false) | |
| 25 | + private Instant joinedAt; | |
| 26 | + | |
| 27 | + protected ChallengeParticipant() { | |
| 28 | + } | |
| 29 | + | |
| 30 | + ChallengeParticipant(UUID id, UUID challengeId, UUID userId, Instant joinedAt) { | |
| 31 | + this.id = id; | |
| 32 | + this.challengeId = challengeId; | |
| 33 | + this.userId = userId; | |
| 34 | + this.joinedAt = joinedAt; | |
| 35 | + } | |
| 36 | + | |
| 37 | + UUID challengeId() { | |
| 38 | + return challengeId; | |
| 39 | + } | |
| 40 | + | |
| 41 | + UUID userId() { | |
| 42 | + return userId; | |
| 43 | + } | |
| 44 | +} |
added backend/src/main/java/com/tasteprint/social/ChallengeParticipantRepository.java +18 −0
| @@ -0,0 +1,18 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | +import java.util.Optional; | |
| 5 | +import java.util.UUID; | |
| 6 | + | |
| 7 | +import org.springframework.data.jpa.repository.JpaRepository; | |
| 8 | + | |
| 9 | +interface ChallengeParticipantRepository extends JpaRepository<ChallengeParticipant, UUID> { | |
| 10 | + | |
| 11 | + List<ChallengeParticipant> findByUserId(UUID userId); | |
| 12 | + | |
| 13 | + List<ChallengeParticipant> findByChallengeId(UUID challengeId); | |
| 14 | + | |
| 15 | + boolean existsByChallengeIdAndUserId(UUID challengeId, UUID userId); | |
| 16 | + | |
| 17 | + Optional<ChallengeParticipant> findByChallengeIdAndUserId(UUID challengeId, UUID userId); | |
| 18 | +} |
added backend/src/main/java/com/tasteprint/social/ChallengeParticipantView.java +12 −0
| @@ -0,0 +1,12 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +import com.tasteprint.account.PublicAccountView; | |
| 6 | + | |
| 7 | +public record ChallengeParticipantView( | |
| 8 | + PublicAccountView user, | |
| 9 | + int contributedDishes, | |
| 10 | + List<String> completedDishSlugs | |
| 11 | +) { | |
| 12 | +} |
added backend/src/main/java/com/tasteprint/social/ChallengeService.java +198 −0
| @@ -0,0 +1,198 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import java.security.SecureRandom; | |
| 4 | +import java.time.Clock; | |
| 5 | +import java.time.LocalDate; | |
| 6 | +import java.util.ArrayList; | |
| 7 | +import java.util.Comparator; | |
| 8 | +import java.util.HashSet; | |
| 9 | +import java.util.List; | |
| 10 | +import java.util.Locale; | |
| 11 | +import java.util.Set; | |
| 12 | +import java.util.UUID; | |
| 13 | + | |
| 14 | +import org.springframework.stereotype.Service; | |
| 15 | +import org.springframework.transaction.annotation.Transactional; | |
| 16 | + | |
| 17 | +import com.tasteprint.account.AccountService; | |
| 18 | +import com.tasteprint.catalog.CatalogService; | |
| 19 | +import com.tasteprint.catalog.DestinationDetails; | |
| 20 | +import com.tasteprint.catalog.DishView; | |
| 21 | +import com.tasteprint.progress.DishProgress; | |
| 22 | +import com.tasteprint.shared.ForbiddenException; | |
| 23 | +import com.tasteprint.shared.NotFoundException; | |
| 24 | +import com.tasteprint.tasting.TastingService; | |
| 25 | + | |
| 26 | +@Service | |
| 27 | +public class ChallengeService { | |
| 28 | + | |
| 29 | + private static final char[] JOIN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".toCharArray(); | |
| 30 | + private static final SecureRandom RANDOM = new SecureRandom(); | |
| 31 | + | |
| 32 | + private final TasteChallengeRepository challenges; | |
| 33 | + private final ChallengeParticipantRepository participants; | |
| 34 | + private final AccountService accounts; | |
| 35 | + private final CatalogService catalog; | |
| 36 | + private final TastingService tastings; | |
| 37 | + private final Clock clock; | |
| 38 | + | |
| 39 | + ChallengeService(TasteChallengeRepository challenges, ChallengeParticipantRepository participants, | |
| 40 | + AccountService accounts, CatalogService catalog, TastingService tastings, Clock clock) { | |
| 41 | + this.challenges = challenges; | |
| 42 | + this.participants = participants; | |
| 43 | + this.accounts = accounts; | |
| 44 | + this.catalog = catalog; | |
| 45 | + this.tastings = tastings; | |
| 46 | + this.clock = clock; | |
| 47 | + } | |
| 48 | + | |
| 49 | + @Transactional | |
| 50 | + public ChallengeView create(UUID userId, CreateChallengeRequest request) { | |
| 51 | + String destinationCode = request.destinationCode().toUpperCase(Locale.ROOT); | |
| 52 | + catalog.requireDestination(destinationCode); | |
| 53 | + TasteChallenge challenge = challenges.save(new TasteChallenge( | |
| 54 | + UUID.randomUUID(), userId, request, destinationCode, uniqueJoinCode(), clock.instant() | |
| 55 | + )); | |
| 56 | + participants.save(new ChallengeParticipant(UUID.randomUUID(), challenge.id(), userId, clock.instant())); | |
| 57 | + return view(challenge); | |
| 58 | + } | |
| 59 | + | |
| 60 | + @Transactional | |
| 61 | + public ChallengeView join(UUID userId, String rawJoinCode) { | |
| 62 | + String joinCode = rawJoinCode.trim().toUpperCase(Locale.ROOT); | |
| 63 | + TasteChallenge challenge = challenges.findByJoinCode(joinCode) | |
| 64 | + .orElseThrow(() -> new NotFoundException("Challenge code was not found.")); | |
| 65 | + if (LocalDate.now(clock).isAfter(challenge.endsOn())) { | |
| 66 | + throw new IllegalArgumentException("This challenge has already ended."); | |
| 67 | + } | |
| 68 | + if (!participants.existsByChallengeIdAndUserId(challenge.id(), userId)) { | |
| 69 | + participants.save(new ChallengeParticipant(UUID.randomUUID(), challenge.id(), userId, clock.instant())); | |
| 70 | + } | |
| 71 | + return view(challenge); | |
| 72 | + } | |
| 73 | + | |
| 74 | + @Transactional(readOnly = true) | |
| 75 | + public List<ChallengeView> list(UUID userId) { | |
| 76 | + return participants.findByUserId(userId).stream() | |
| 77 | + .map(ChallengeParticipant::challengeId) | |
| 78 | + .distinct() | |
| 79 | + .map(challenges::findById) | |
| 80 | + .flatMap(java.util.Optional::stream) | |
| 81 | + .map(this::view) | |
| 82 | + .sorted(Comparator.comparing((ChallengeView challenge) -> statusOrder(challenge.status())) | |
| 83 | + .thenComparing(ChallengeView::startsOn)) | |
| 84 | + .toList(); | |
| 85 | + } | |
| 86 | + | |
| 87 | + @Transactional(readOnly = true) | |
| 88 | + public ChallengeView get(UUID userId, UUID challengeId) { | |
| 89 | + TasteChallenge challenge = required(challengeId); | |
| 90 | + requireParticipant(challengeId, userId); | |
| 91 | + return view(challenge); | |
| 92 | + } | |
| 93 | + | |
| 94 | + @Transactional | |
| 95 | + public void leave(UUID userId, UUID challengeId) { | |
| 96 | + TasteChallenge challenge = required(challengeId); | |
| 97 | + if (challenge.ownerId().equals(userId)) { | |
| 98 | + throw new IllegalArgumentException("The owner can delete the challenge but cannot leave it."); | |
| 99 | + } | |
| 100 | + ChallengeParticipant participant = participants.findByChallengeIdAndUserId(challengeId, userId) | |
| 101 | + .orElseThrow(() -> new NotFoundException("You are not part of this challenge.")); | |
| 102 | + participants.delete(participant); | |
| 103 | + } | |
| 104 | + | |
| 105 | + @Transactional | |
| 106 | + public void delete(UUID userId, UUID challengeId) { | |
| 107 | + TasteChallenge challenge = required(challengeId); | |
| 108 | + if (!challenge.ownerId().equals(userId)) { | |
| 109 | + throw new ForbiddenException("Only the challenge owner can delete it."); | |
| 110 | + } | |
| 111 | + challenges.delete(challenge); | |
| 112 | + } | |
| 113 | + | |
| 114 | + @Transactional(readOnly = true) | |
| 115 | + public boolean hasAny(UUID userId) { | |
| 116 | + return !participants.findByUserId(userId).isEmpty(); | |
| 117 | + } | |
| 118 | + | |
| 119 | + private ChallengeView view(TasteChallenge challenge) { | |
| 120 | + DestinationDetails destination = catalog.destination(challenge.destinationCode()); | |
| 121 | + Set<String> destinationSlugs = destination.dishes().stream() | |
| 122 | + .map(DishView::slug) | |
| 123 | + .collect(java.util.stream.Collectors.toSet()); | |
| 124 | + Set<String> groupCompleted = new HashSet<>(); | |
| 125 | + List<ChallengeParticipantView> memberViews = new ArrayList<>(); | |
| 126 | + | |
| 127 | + for (ChallengeParticipant participant : participants.findByChallengeId(challenge.id())) { | |
| 128 | + Set<String> completed = tastings.triedDishSlugsBetween( | |
| 129 | + participant.userId(), challenge.startsOn(), challenge.endsOn() | |
| 130 | + ); | |
| 131 | + completed.retainAll(destinationSlugs); | |
| 132 | + groupCompleted.addAll(completed); | |
| 133 | + memberViews.add(new ChallengeParticipantView( | |
| 134 | + accounts.getPublicById(participant.userId()), completed.size(), completed.stream().sorted().toList() | |
| 135 | + )); | |
| 136 | + } | |
| 137 | + memberViews.sort(Comparator.comparingInt(ChallengeParticipantView::contributedDishes).reversed()); | |
| 138 | + | |
| 139 | + int totalWeight = destination.dishes().stream().mapToInt(DishView::importance).sum(); | |
| 140 | + int completedWeight = destination.dishes().stream() | |
| 141 | + .filter(dish -> groupCompleted.contains(dish.slug())) | |
| 142 | + .mapToInt(DishView::importance) | |
| 143 | + .sum(); | |
| 144 | + int coverage = totalWeight == 0 ? 0 : (int) Math.round(completedWeight * 100.0 / totalWeight); | |
| 145 | + List<DishProgress> dishProgress = destination.dishes().stream() | |
| 146 | + .map(dish -> new DishProgress(dish, groupCompleted.contains(dish.slug()))) | |
| 147 | + .toList(); | |
| 148 | + | |
| 149 | + return new ChallengeView( | |
| 150 | + challenge.id(), challenge.ownerId(), challenge.title(), destination.destination(), | |
| 151 | + challenge.joinCode(), challenge.startsOn(), challenge.endsOn(), status(challenge), | |
| 152 | + coverage, dishProgress, memberViews | |
| 153 | + ); | |
| 154 | + } | |
| 155 | + | |
| 156 | + private TasteChallenge required(UUID challengeId) { | |
| 157 | + return challenges.findById(challengeId) | |
| 158 | + .orElseThrow(() -> new NotFoundException("Challenge was not found.")); | |
| 159 | + } | |
| 160 | + | |
| 161 | + private void requireParticipant(UUID challengeId, UUID userId) { | |
| 162 | + if (!participants.existsByChallengeIdAndUserId(challengeId, userId)) { | |
| 163 | + throw new ForbiddenException("Join the challenge before opening it."); | |
| 164 | + } | |
| 165 | + } | |
| 166 | + | |
| 167 | + private ChallengeStatus status(TasteChallenge challenge) { | |
| 168 | + LocalDate today = LocalDate.now(clock); | |
| 169 | + if (today.isBefore(challenge.startsOn())) { | |
| 170 | + return ChallengeStatus.UPCOMING; | |
| 171 | + } | |
| 172 | + if (today.isAfter(challenge.endsOn())) { | |
| 173 | + return ChallengeStatus.COMPLETED; | |
| 174 | + } | |
| 175 | + return ChallengeStatus.ACTIVE; | |
| 176 | + } | |
| 177 | + | |
| 178 | + private int statusOrder(ChallengeStatus status) { | |
| 179 | + return switch (status) { | |
| 180 | + case ACTIVE -> 0; | |
| 181 | + case UPCOMING -> 1; | |
| 182 | + case COMPLETED -> 2; | |
| 183 | + }; | |
| 184 | + } | |
| 185 | + | |
| 186 | + private String uniqueJoinCode() { | |
| 187 | + for (int attempt = 0; attempt < 30; attempt++) { | |
| 188 | + StringBuilder code = new StringBuilder(6); | |
| 189 | + for (int index = 0; index < 6; index++) { | |
| 190 | + code.append(JOIN_ALPHABET[RANDOM.nextInt(JOIN_ALPHABET.length)]); | |
| 191 | + } | |
| 192 | + if (!challenges.existsByJoinCode(code.toString())) { | |
| 193 | + return code.toString(); | |
| 194 | + } | |
| 195 | + } | |
| 196 | + throw new IllegalStateException("Could not create a unique challenge code."); | |
| 197 | + } | |
| 198 | +} |
added backend/src/main/java/com/tasteprint/social/ChallengeStatus.java +7 −0
| @@ -0,0 +1,7 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +public enum ChallengeStatus { | |
| 4 | + UPCOMING, | |
| 5 | + ACTIVE, | |
| 6 | + COMPLETED | |
| 7 | +} |
added backend/src/main/java/com/tasteprint/social/ChallengeView.java +23 −0
| @@ -0,0 +1,23 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import java.time.LocalDate; | |
| 4 | +import java.util.List; | |
| 5 | +import java.util.UUID; | |
| 6 | + | |
| 7 | +import com.tasteprint.catalog.DestinationSummary; | |
| 8 | +import com.tasteprint.progress.DishProgress; | |
| 9 | + | |
| 10 | +public record ChallengeView( | |
| 11 | + UUID id, | |
| 12 | + UUID ownerId, | |
| 13 | + String title, | |
| 14 | + DestinationSummary destination, | |
| 15 | + String joinCode, | |
| 16 | + LocalDate startsOn, | |
| 17 | + LocalDate endsOn, | |
| 18 | + ChallengeStatus status, | |
| 19 | + int groupCoverage, | |
| 20 | + List<DishProgress> dishes, | |
| 21 | + List<ChallengeParticipantView> participants | |
| 22 | +) { | |
| 23 | +} |
added backend/src/main/java/com/tasteprint/social/CreateChallengeRequest.java +27 −0
| @@ -0,0 +1,27 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import java.time.LocalDate; | |
| 4 | +import java.time.temporal.ChronoUnit; | |
| 5 | + | |
| 6 | +import jakarta.validation.constraints.NotBlank; | |
| 7 | +import jakarta.validation.constraints.NotNull; | |
| 8 | +import jakarta.validation.constraints.Pattern; | |
| 9 | +import jakarta.validation.constraints.Size; | |
| 10 | + | |
| 11 | +public record CreateChallengeRequest( | |
| 12 | + @NotBlank @Size(max = 100) String title, | |
| 13 | + @NotBlank @Pattern(regexp = "^[A-Za-z]{2}$", message = "must be a two-letter country code") String destinationCode, | |
| 14 | + @NotNull LocalDate startsOn, | |
| 15 | + @NotNull LocalDate endsOn | |
| 16 | +) { | |
| 17 | + public CreateChallengeRequest { | |
| 18 | + if (startsOn != null && endsOn != null) { | |
| 19 | + if (endsOn.isBefore(startsOn)) { | |
| 20 | + throw new IllegalArgumentException("Challenge end date cannot be before its start date."); | |
| 21 | + } | |
| 22 | + if (ChronoUnit.DAYS.between(startsOn, endsOn) > 180) { | |
| 23 | + throw new IllegalArgumentException("A challenge can be at most 180 days long."); | |
| 24 | + } | |
| 25 | + } | |
| 26 | + } | |
| 27 | +} |
added backend/src/main/java/com/tasteprint/social/JoinChallengeRequest.java +9 −0
| @@ -0,0 +1,9 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import jakarta.validation.constraints.NotBlank; | |
| 4 | +import jakarta.validation.constraints.Pattern; | |
| 5 | + | |
| 6 | +public record JoinChallengeRequest( | |
| 7 | + @NotBlank @Pattern(regexp = "^[A-Za-z0-9]{6,8}$", message = "must be a valid join code") String joinCode | |
| 8 | +) { | |
| 9 | +} |
added backend/src/main/java/com/tasteprint/social/PublicTasteSnapshot.java +15 −0
| @@ -0,0 +1,15 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +import com.tasteprint.catalog.DishView; | |
| 6 | +import com.tasteprint.progress.DestinationProgress; | |
| 7 | +import com.tasteprint.progress.TasteStats; | |
| 8 | + | |
| 9 | +public record PublicTasteSnapshot( | |
| 10 | + TasteStats stats, | |
| 11 | + List<DestinationProgress> destinations, | |
| 12 | + List<DishView> importantMissing, | |
| 13 | + List<PublicTastingView> recentTastings | |
| 14 | +) { | |
| 15 | +} |
added backend/src/main/java/com/tasteprint/social/PublicTasteprintView.java +6 −0
| @@ -0,0 +1,6 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import com.tasteprint.account.PublicAccountView; | |
| 4 | + | |
| 5 | +public record PublicTasteprintView(PublicAccountView user, PublicTasteSnapshot tasteprint) { | |
| 6 | +} |
added backend/src/main/java/com/tasteprint/social/PublicTastingView.java +18 −0
| @@ -0,0 +1,18 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import java.time.LocalDate; | |
| 4 | +import java.util.UUID; | |
| 5 | + | |
| 6 | +import com.tasteprint.catalog.DishView; | |
| 7 | + | |
| 8 | +public record PublicTastingView( | |
| 9 | + UUID id, | |
| 10 | + DishView dish, | |
| 11 | + String city, | |
| 12 | + String countryCode, | |
| 13 | + LocalDate tastedOn, | |
| 14 | + Integer rating, | |
| 15 | + String note, | |
| 16 | + String photoUrl | |
| 17 | +) { | |
| 18 | +} |
added backend/src/main/java/com/tasteprint/social/SharingController.java +30 −0
| @@ -0,0 +1,30 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import org.springframework.security.core.annotation.AuthenticationPrincipal; | |
| 4 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 5 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 6 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 7 | +import org.springframework.web.bind.annotation.RestController; | |
| 8 | + | |
| 9 | +import com.tasteprint.account.AuthenticatedUser; | |
| 10 | + | |
| 11 | +@RestController | |
| 12 | +class SharingController { | |
| 13 | + | |
| 14 | + private final SharingService sharing; | |
| 15 | + | |
| 16 | + SharingController(SharingService sharing) { | |
| 17 | + this.sharing = sharing; | |
| 18 | + } | |
| 19 | + | |
| 20 | + @GetMapping("/api/v1/public/tasteprints/{shareSlug}") | |
| 21 | + PublicTasteprintView publicTasteprint(@PathVariable String shareSlug) { | |
| 22 | + return sharing.publicTasteprint(shareSlug); | |
| 23 | + } | |
| 24 | + | |
| 25 | + @GetMapping("/api/v1/social/compare/{shareSlug}") | |
| 26 | + TasteComparison compare(@AuthenticationPrincipal AuthenticatedUser user, | |
| 27 | + @PathVariable String shareSlug) { | |
| 28 | + return sharing.compare(user.id(), shareSlug); | |
| 29 | + } | |
| 30 | +} |
added backend/src/main/java/com/tasteprint/social/SharingService.java +124 −0
| @@ -0,0 +1,124 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import java.util.Comparator; | |
| 4 | +import java.util.HashSet; | |
| 5 | +import java.util.List; | |
| 6 | +import java.util.Map; | |
| 7 | +import java.util.Set; | |
| 8 | +import java.util.UUID; | |
| 9 | +import java.util.function.Function; | |
| 10 | +import java.util.stream.Collectors; | |
| 11 | + | |
| 12 | +import org.springframework.stereotype.Service; | |
| 13 | +import org.springframework.transaction.annotation.Transactional; | |
| 14 | + | |
| 15 | +import com.tasteprint.account.AccountService; | |
| 16 | +import com.tasteprint.account.PublicAccountView; | |
| 17 | +import com.tasteprint.catalog.CatalogService; | |
| 18 | +import com.tasteprint.catalog.DestinationSummary; | |
| 19 | +import com.tasteprint.catalog.DishView; | |
| 20 | +import com.tasteprint.progress.ProgressService; | |
| 21 | +import com.tasteprint.progress.TasteSnapshot; | |
| 22 | +import com.tasteprint.tasting.TastingService; | |
| 23 | +import com.tasteprint.tasting.TastingView; | |
| 24 | + | |
| 25 | +@Service | |
| 26 | +public class SharingService { | |
| 27 | + | |
| 28 | + private final AccountService accounts; | |
| 29 | + private final CatalogService catalog; | |
| 30 | + private final TastingService tastings; | |
| 31 | + private final ProgressService progress; | |
| 32 | + | |
| 33 | + SharingService(AccountService accounts, CatalogService catalog, | |
| 34 | + TastingService tastings, ProgressService progress) { | |
| 35 | + this.accounts = accounts; | |
| 36 | + this.catalog = catalog; | |
| 37 | + this.tastings = tastings; | |
| 38 | + this.progress = progress; | |
| 39 | + } | |
| 40 | + | |
| 41 | + @Transactional(readOnly = true) | |
| 42 | + public PublicTasteprintView publicTasteprint(String shareSlug) { | |
| 43 | + PublicAccountView account = accounts.getPublic(shareSlug); | |
| 44 | + return new PublicTasteprintView(account, publicSnapshot(progress.snapshot(account.id()))); | |
| 45 | + } | |
| 46 | + | |
| 47 | + @Transactional(readOnly = true) | |
| 48 | + public TasteComparison compare(UUID currentUserId, String otherShareSlug) { | |
| 49 | + PublicAccountView other = accounts.getPublic(otherShareSlug); | |
| 50 | + Set<String> yours = tastings.triedDishSlugs(currentUserId); | |
| 51 | + Set<String> theirs = tastings.triedDishSlugs(other.id()); | |
| 52 | + Set<String> sharedDishes = intersection(yours, theirs); | |
| 53 | + Set<String> union = new HashSet<>(yours); | |
| 54 | + union.addAll(theirs); | |
| 55 | + | |
| 56 | + List<DishView> allDishes = catalog.allDishes(); | |
| 57 | + Map<String, DishView> bySlug = allDishes.stream() | |
| 58 | + .collect(Collectors.toMap(DishView::slug, Function.identity())); | |
| 59 | + Set<String> yourCountries = destinationCodes(yours, bySlug); | |
| 60 | + Set<String> theirCountries = destinationCodes(theirs, bySlug); | |
| 61 | + Set<String> sharedCountries = intersection(yourCountries, theirCountries); | |
| 62 | + Set<String> yourUnique = difference(yourCountries, theirCountries); | |
| 63 | + Set<String> theirUnique = difference(theirCountries, yourCountries); | |
| 64 | + Map<String, DestinationSummary> destinations = catalog.destinations().stream() | |
| 65 | + .collect(Collectors.toMap(DestinationSummary::code, Function.identity())); | |
| 66 | + | |
| 67 | + DishView suggestion = allDishes.stream() | |
| 68 | + .filter(dish -> theirs.contains(dish.slug()) && !yours.contains(dish.slug())) | |
| 69 | + .sorted(Comparator.comparingInt(DishView::importance).reversed()) | |
| 70 | + .findFirst() | |
| 71 | + .orElseGet(() -> allDishes.stream() | |
| 72 | + .filter(dish -> !yours.contains(dish.slug())) | |
| 73 | + .max(Comparator.comparingInt(DishView::importance)) | |
| 74 | + .orElse(null)); | |
| 75 | + | |
| 76 | + int overlap = union.isEmpty() ? 0 : (int) Math.round(sharedDishes.size() * 100.0 / union.size()); | |
| 77 | + return new TasteComparison( | |
| 78 | + other, overlap, sharedDishes.size(), summaries(sharedCountries, destinations), | |
| 79 | + summaries(yourUnique, destinations), summaries(theirUnique, destinations), suggestion | |
| 80 | + ); | |
| 81 | + } | |
| 82 | + | |
| 83 | + private Set<String> destinationCodes(Set<String> dishSlugs, Map<String, DishView> bySlug) { | |
| 84 | + return dishSlugs.stream() | |
| 85 | + .map(bySlug::get) | |
| 86 | + .filter(java.util.Objects::nonNull) | |
| 87 | + .map(DishView::destinationCode) | |
| 88 | + .collect(Collectors.toSet()); | |
| 89 | + } | |
| 90 | + | |
| 91 | + private PublicTasteSnapshot publicSnapshot(TasteSnapshot snapshot) { | |
| 92 | + return new PublicTasteSnapshot( | |
| 93 | + snapshot.stats(), snapshot.destinations(), snapshot.importantMissing(), | |
| 94 | + snapshot.recentTastings().stream().map(this::publicTasting).toList() | |
| 95 | + ); | |
| 96 | + } | |
| 97 | + | |
| 98 | + private PublicTastingView publicTasting(TastingView tasting) { | |
| 99 | + return new PublicTastingView( | |
| 100 | + tasting.id(), tasting.dish(), tasting.city(), tasting.countryCode(), tasting.tastedOn(), | |
| 101 | + tasting.rating(), tasting.note(), tasting.photoUrl() | |
| 102 | + ); | |
| 103 | + } | |
| 104 | + | |
| 105 | + private List<DestinationSummary> summaries(Set<String> codes, Map<String, DestinationSummary> destinations) { | |
| 106 | + return codes.stream() | |
| 107 | + .map(destinations::get) | |
| 108 | + .filter(java.util.Objects::nonNull) | |
| 109 | + .sorted(Comparator.comparing(DestinationSummary::name)) | |
| 110 | + .toList(); | |
| 111 | + } | |
| 112 | + | |
| 113 | + private <T> Set<T> intersection(Set<T> first, Set<T> second) { | |
| 114 | + Set<T> result = new HashSet<>(first); | |
| 115 | + result.retainAll(second); | |
| 116 | + return result; | |
| 117 | + } | |
| 118 | + | |
| 119 | + private <T> Set<T> difference(Set<T> first, Set<T> second) { | |
| 120 | + Set<T> result = new HashSet<>(first); | |
| 121 | + result.removeAll(second); | |
| 122 | + return result; | |
| 123 | + } | |
| 124 | +} |
added backend/src/main/java/com/tasteprint/social/TasteChallenge.java +82 −0
| @@ -0,0 +1,82 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import java.time.Instant; | |
| 4 | +import java.time.LocalDate; | |
| 5 | +import java.util.UUID; | |
| 6 | + | |
| 7 | +import jakarta.persistence.Column; | |
| 8 | +import jakarta.persistence.Entity; | |
| 9 | +import jakarta.persistence.Id; | |
| 10 | +import jakarta.persistence.Table; | |
| 11 | + | |
| 12 | +@Entity | |
| 13 | +@Table(name = "taste_challenge") | |
| 14 | +class TasteChallenge { | |
| 15 | + | |
| 16 | + @Id | |
| 17 | + private UUID id; | |
| 18 | + | |
| 19 | + @Column(name = "owner_id", nullable = false) | |
| 20 | + private UUID ownerId; | |
| 21 | + | |
| 22 | + @Column(nullable = false, length = 100) | |
| 23 | + private String title; | |
| 24 | + | |
| 25 | + @Column(name = "destination_code", nullable = false, length = 2) | |
| 26 | + private String destinationCode; | |
| 27 | + | |
| 28 | + @Column(name = "join_code", nullable = false, length = 8, unique = true) | |
| 29 | + private String joinCode; | |
| 30 | + | |
| 31 | + @Column(name = "starts_on", nullable = false) | |
| 32 | + private LocalDate startsOn; | |
| 33 | + | |
| 34 | + @Column(name = "ends_on", nullable = false) | |
| 35 | + private LocalDate endsOn; | |
| 36 | + | |
| 37 | + @Column(name = "created_at", nullable = false) | |
| 38 | + private Instant createdAt; | |
| 39 | + | |
| 40 | + protected TasteChallenge() { | |
| 41 | + } | |
| 42 | + | |
| 43 | + TasteChallenge(UUID id, UUID ownerId, CreateChallengeRequest request, | |
| 44 | + String destinationCode, String joinCode, Instant now) { | |
| 45 | + this.id = id; | |
| 46 | + this.ownerId = ownerId; | |
| 47 | + this.title = request.title().trim(); | |
| 48 | + this.destinationCode = destinationCode; | |
| 49 | + this.joinCode = joinCode; | |
| 50 | + this.startsOn = request.startsOn(); | |
| 51 | + this.endsOn = request.endsOn(); | |
| 52 | + this.createdAt = now; | |
| 53 | + } | |
| 54 | + | |
| 55 | + UUID id() { | |
| 56 | + return id; | |
| 57 | + } | |
| 58 | + | |
| 59 | + UUID ownerId() { | |
| 60 | + return ownerId; | |
| 61 | + } | |
| 62 | + | |
| 63 | + String title() { | |
| 64 | + return title; | |
| 65 | + } | |
| 66 | + | |
| 67 | + String destinationCode() { | |
| 68 | + return destinationCode; | |
| 69 | + } | |
| 70 | + | |
| 71 | + String joinCode() { | |
| 72 | + return joinCode; | |
| 73 | + } | |
| 74 | + | |
| 75 | + LocalDate startsOn() { | |
| 76 | + return startsOn; | |
| 77 | + } | |
| 78 | + | |
| 79 | + LocalDate endsOn() { | |
| 80 | + return endsOn; | |
| 81 | + } | |
| 82 | +} |
added backend/src/main/java/com/tasteprint/social/TasteChallengeRepository.java +20 −0
| @@ -0,0 +1,20 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import java.util.Optional; | |
| 4 | +import java.util.UUID; | |
| 5 | + | |
| 6 | +import org.springframework.data.jpa.repository.JpaRepository; | |
| 7 | +import org.springframework.data.jpa.repository.Modifying; | |
| 8 | +import org.springframework.data.jpa.repository.Query; | |
| 9 | +import org.springframework.data.repository.query.Param; | |
| 10 | + | |
| 11 | +interface TasteChallengeRepository extends JpaRepository<TasteChallenge, UUID> { | |
| 12 | + | |
| 13 | + Optional<TasteChallenge> findByJoinCode(String joinCode); | |
| 14 | + | |
| 15 | + boolean existsByJoinCode(String joinCode); | |
| 16 | + | |
| 17 | + @Modifying(clearAutomatically = true, flushAutomatically = true) | |
| 18 | + @Query("delete from TasteChallenge challenge where challenge.ownerId = :ownerId") | |
| 19 | + void deleteByOwnerId(@Param("ownerId") UUID ownerId); | |
| 20 | +} |
added backend/src/main/java/com/tasteprint/social/TasteComparison.java +18 −0
| @@ -0,0 +1,18 @@ | ||
| 1 | +package com.tasteprint.social; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +import com.tasteprint.account.PublicAccountView; | |
| 6 | +import com.tasteprint.catalog.DestinationSummary; | |
| 7 | +import com.tasteprint.catalog.DishView; | |
| 8 | + | |
| 9 | +public record TasteComparison( | |
| 10 | + PublicAccountView otherUser, | |
| 11 | + int overlapScore, | |
| 12 | + int sharedDishes, | |
| 13 | + List<DestinationSummary> sharedCountries, | |
| 14 | + List<DestinationSummary> yourUniqueCountries, | |
| 15 | + List<DestinationSummary> theirUniqueCountries, | |
| 16 | + DishView suggestedSharedBite | |
| 17 | +) { | |
| 18 | +} |
added backend/src/main/java/com/tasteprint/social/package-info.java +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +@org.springframework.modulith.ApplicationModule( | |
| 2 | + displayName = "Sharing and challenges", | |
| 3 | + allowedDependencies = {"account", "catalog", "progress", "tasting", "shared"} | |
| 4 | +) | |
| 5 | +package com.tasteprint.social; |
added backend/src/main/java/com/tasteprint/tasting/SaveTastingRequest.java +32 −0
| @@ -0,0 +1,32 @@ | ||
| 1 | +package com.tasteprint.tasting; | |
| 2 | + | |
| 3 | +import java.time.LocalDate; | |
| 4 | + | |
| 5 | +import jakarta.validation.constraints.DecimalMax; | |
| 6 | +import jakarta.validation.constraints.DecimalMin; | |
| 7 | +import jakarta.validation.constraints.Max; | |
| 8 | +import jakarta.validation.constraints.Min; | |
| 9 | +import jakarta.validation.constraints.NotBlank; | |
| 10 | +import jakarta.validation.constraints.NotNull; | |
| 11 | +import jakarta.validation.constraints.PastOrPresent; | |
| 12 | +import jakarta.validation.constraints.Pattern; | |
| 13 | +import jakarta.validation.constraints.Size; | |
| 14 | + | |
| 15 | +public record SaveTastingRequest( | |
| 16 | + @NotBlank @Size(max = 120) String dishSlug, | |
| 17 | + @Size(max = 160) String restaurantName, | |
| 18 | + @NotBlank @Size(max = 100) String city, | |
| 19 | + @NotBlank @Pattern(regexp = "^[A-Za-z]{2}$", message = "must be a two-letter country code") String countryCode, | |
| 20 | + @NotNull @PastOrPresent LocalDate tastedOn, | |
| 21 | + @NotNull @Min(1) @Max(5) Integer rating, | |
| 22 | + @Size(max = 500) String note, | |
| 23 | + @Size(max = 500) @Pattern(regexp = "^$|^https://.+|^/uploads/[A-Za-z0-9._-]+$", message = "must be an HTTPS URL or an uploaded photo") String photoUrl, | |
| 24 | + @DecimalMin("-90.0") @DecimalMax("90.0") Double latitude, | |
| 25 | + @DecimalMin("-180.0") @DecimalMax("180.0") Double longitude | |
| 26 | +) { | |
| 27 | + public SaveTastingRequest { | |
| 28 | + if ((latitude == null) != (longitude == null)) { | |
| 29 | + throw new IllegalArgumentException("Latitude and longitude must be provided together."); | |
| 30 | + } | |
| 31 | + } | |
| 32 | +} |
added backend/src/main/java/com/tasteprint/tasting/Tasting.java +140 −0
| @@ -0,0 +1,140 @@ | ||
| 1 | +package com.tasteprint.tasting; | |
| 2 | + | |
| 3 | +import java.time.Instant; | |
| 4 | +import java.time.LocalDate; | |
| 5 | +import java.util.UUID; | |
| 6 | + | |
| 7 | +import jakarta.persistence.Column; | |
| 8 | +import jakarta.persistence.Entity; | |
| 9 | +import jakarta.persistence.Id; | |
| 10 | +import jakarta.persistence.Table; | |
| 11 | + | |
| 12 | +@Entity | |
| 13 | +@Table(name = "tasting") | |
| 14 | +class Tasting { | |
| 15 | + | |
| 16 | + @Id | |
| 17 | + private UUID id; | |
| 18 | + | |
| 19 | + @Column(name = "user_id", nullable = false) | |
| 20 | + private UUID userId; | |
| 21 | + | |
| 22 | + @Column(name = "dish_slug", nullable = false, length = 120) | |
| 23 | + private String dishSlug; | |
| 24 | + | |
| 25 | + @Column(name = "restaurant_name", length = 160) | |
| 26 | + private String restaurantName; | |
| 27 | + | |
| 28 | + @Column(nullable = false, length = 100) | |
| 29 | + private String city; | |
| 30 | + | |
| 31 | + @Column(name = "country_code", nullable = false, length = 2) | |
| 32 | + private String countryCode; | |
| 33 | + | |
| 34 | + @Column(name = "tasted_on", nullable = false) | |
| 35 | + private LocalDate tastedOn; | |
| 36 | + | |
| 37 | + @Column(nullable = false) | |
| 38 | + private short rating; | |
| 39 | + | |
| 40 | + @Column(length = 500) | |
| 41 | + private String note; | |
| 42 | + | |
| 43 | + @Column(name = "photo_url", length = 500) | |
| 44 | + private String photoUrl; | |
| 45 | + | |
| 46 | + private Double latitude; | |
| 47 | + | |
| 48 | + private Double longitude; | |
| 49 | + | |
| 50 | + @Column(name = "created_at", nullable = false) | |
| 51 | + private Instant createdAt; | |
| 52 | + | |
| 53 | + @Column(name = "updated_at", nullable = false) | |
| 54 | + private Instant updatedAt; | |
| 55 | + | |
| 56 | + protected Tasting() { | |
| 57 | + } | |
| 58 | + | |
| 59 | + Tasting(UUID id, UUID userId, SaveTastingRequest request, String countryCode, Instant now) { | |
| 60 | + this.id = id; | |
| 61 | + this.userId = userId; | |
| 62 | + this.dishSlug = request.dishSlug(); | |
| 63 | + apply(request, countryCode, now); | |
| 64 | + this.createdAt = now; | |
| 65 | + } | |
| 66 | + | |
| 67 | + void update(SaveTastingRequest request, String countryCode, Instant now) { | |
| 68 | + this.dishSlug = request.dishSlug(); | |
| 69 | + apply(request, countryCode, now); | |
| 70 | + } | |
| 71 | + | |
| 72 | + private void apply(SaveTastingRequest request, String countryCode, Instant now) { | |
| 73 | + this.restaurantName = clean(request.restaurantName()); | |
| 74 | + this.city = request.city().trim(); | |
| 75 | + this.countryCode = countryCode; | |
| 76 | + this.tastedOn = request.tastedOn(); | |
| 77 | + this.rating = request.rating().shortValue(); | |
| 78 | + this.note = clean(request.note()); | |
| 79 | + this.photoUrl = clean(request.photoUrl()); | |
| 80 | + this.latitude = request.latitude(); | |
| 81 | + this.longitude = request.longitude(); | |
| 82 | + this.updatedAt = now; | |
| 83 | + } | |
| 84 | + | |
| 85 | + private String clean(String value) { | |
| 86 | + return value == null || value.isBlank() ? null : value.trim(); | |
| 87 | + } | |
| 88 | + | |
| 89 | + UUID id() { | |
| 90 | + return id; | |
| 91 | + } | |
| 92 | + | |
| 93 | + UUID userId() { | |
| 94 | + return userId; | |
| 95 | + } | |
| 96 | + | |
| 97 | + String dishSlug() { | |
| 98 | + return dishSlug; | |
| 99 | + } | |
| 100 | + | |
| 101 | + String restaurantName() { | |
| 102 | + return restaurantName; | |
| 103 | + } | |
| 104 | + | |
| 105 | + String city() { | |
| 106 | + return city; | |
| 107 | + } | |
| 108 | + | |
| 109 | + String countryCode() { | |
| 110 | + return countryCode; | |
| 111 | + } | |
| 112 | + | |
| 113 | + LocalDate tastedOn() { | |
| 114 | + return tastedOn; | |
| 115 | + } | |
| 116 | + | |
| 117 | + int rating() { | |
| 118 | + return rating; | |
| 119 | + } | |
| 120 | + | |
| 121 | + String note() { | |
| 122 | + return note; | |
| 123 | + } | |
| 124 | + | |
| 125 | + String photoUrl() { | |
| 126 | + return photoUrl; | |
| 127 | + } | |
| 128 | + | |
| 129 | + Double latitude() { | |
| 130 | + return latitude; | |
| 131 | + } | |
| 132 | + | |
| 133 | + Double longitude() { | |
| 134 | + return longitude; | |
| 135 | + } | |
| 136 | + | |
| 137 | + Instant createdAt() { | |
| 138 | + return createdAt; | |
| 139 | + } | |
| 140 | +} |
added backend/src/main/java/com/tasteprint/tasting/TastingController.java +58 −0
| @@ -0,0 +1,58 @@ | ||
| 1 | +package com.tasteprint.tasting; | |
| 2 | + | |
| 3 | +import java.util.UUID; | |
| 4 | + | |
| 5 | +import jakarta.validation.Valid; | |
| 6 | + | |
| 7 | +import org.springframework.http.HttpStatus; | |
| 8 | +import org.springframework.security.core.annotation.AuthenticationPrincipal; | |
| 9 | +import org.springframework.web.bind.annotation.DeleteMapping; | |
| 10 | +import org.springframework.web.bind.annotation.GetMapping; | |
| 11 | +import org.springframework.web.bind.annotation.PathVariable; | |
| 12 | +import org.springframework.web.bind.annotation.PostMapping; | |
| 13 | +import org.springframework.web.bind.annotation.PutMapping; | |
| 14 | +import org.springframework.web.bind.annotation.RequestBody; | |
| 15 | +import org.springframework.web.bind.annotation.RequestMapping; | |
| 16 | +import org.springframework.web.bind.annotation.RequestParam; | |
| 17 | +import org.springframework.web.bind.annotation.ResponseStatus; | |
| 18 | +import org.springframework.web.bind.annotation.RestController; | |
| 19 | + | |
| 20 | +import com.tasteprint.account.AuthenticatedUser; | |
| 21 | + | |
| 22 | +@RestController | |
| 23 | +@RequestMapping("/api/v1/tastings") | |
| 24 | +class TastingController { | |
| 25 | + | |
| 26 | + private final TastingService tastings; | |
| 27 | + | |
| 28 | + TastingController(TastingService tastings) { | |
| 29 | + this.tastings = tastings; | |
| 30 | + } | |
| 31 | + | |
| 32 | + @GetMapping | |
| 33 | + TastingPage list(@AuthenticationPrincipal AuthenticatedUser user, | |
| 34 | + @RequestParam(defaultValue = "0") int page, | |
| 35 | + @RequestParam(defaultValue = "20") int size) { | |
| 36 | + return tastings.page(user.id(), page, size); | |
| 37 | + } | |
| 38 | + | |
| 39 | + @PostMapping | |
| 40 | + @ResponseStatus(HttpStatus.CREATED) | |
| 41 | + TastingView record(@AuthenticationPrincipal AuthenticatedUser user, | |
| 42 | + @Valid @RequestBody SaveTastingRequest request) { | |
| 43 | + return tastings.record(user.id(), request); | |
| 44 | + } | |
| 45 | + | |
| 46 | + @PutMapping("/{tastingId}") | |
| 47 | + TastingView update(@AuthenticationPrincipal AuthenticatedUser user, | |
| 48 | + @PathVariable UUID tastingId, | |
| 49 | + @Valid @RequestBody SaveTastingRequest request) { | |
| 50 | + return tastings.update(user.id(), tastingId, request); | |
| 51 | + } | |
| 52 | + | |
| 53 | + @DeleteMapping("/{tastingId}") | |
| 54 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 55 | + void delete(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable UUID tastingId) { | |
| 56 | + tastings.delete(user.id(), tastingId); | |
| 57 | + } | |
| 58 | +} |
added backend/src/main/java/com/tasteprint/tasting/TastingPage.java +13 −0
| @@ -0,0 +1,13 @@ | ||
| 1 | +package com.tasteprint.tasting; | |
| 2 | + | |
| 3 | +import java.util.List; | |
| 4 | + | |
| 5 | +public record TastingPage( | |
| 6 | + List<TastingView> items, | |
| 7 | + int page, | |
| 8 | + int size, | |
| 9 | + long totalItems, | |
| 10 | + int totalPages, | |
| 11 | + boolean hasMore | |
| 12 | +) { | |
| 13 | +} |
added backend/src/main/java/com/tasteprint/tasting/TastingRecorded.java +7 −0
| @@ -0,0 +1,7 @@ | ||
| 1 | +package com.tasteprint.tasting; | |
| 2 | + | |
| 3 | +import java.time.LocalDate; | |
| 4 | +import java.util.UUID; | |
| 5 | + | |
| 6 | +public record TastingRecorded(UUID tastingId, UUID userId, String dishSlug, LocalDate tastedOn) { | |
| 7 | +} |
added backend/src/main/java/com/tasteprint/tasting/TastingRepository.java +52 −0
| @@ -0,0 +1,52 @@ | ||
| 1 | +package com.tasteprint.tasting; | |
| 2 | + | |
| 3 | +import java.time.LocalDate; | |
| 4 | +import java.util.List; | |
| 5 | +import java.util.Optional; | |
| 6 | +import java.util.UUID; | |
| 7 | + | |
| 8 | +import org.springframework.data.domain.Page; | |
| 9 | +import org.springframework.data.domain.Pageable; | |
| 10 | +import org.springframework.data.jpa.repository.JpaRepository; | |
| 11 | +import org.springframework.data.jpa.repository.Query; | |
| 12 | +import org.springframework.data.repository.query.Param; | |
| 13 | + | |
| 14 | +interface TastingRepository extends JpaRepository<Tasting, UUID> { | |
| 15 | + | |
| 16 | + Optional<Tasting> findByIdAndUserId(UUID id, UUID userId); | |
| 17 | + | |
| 18 | + Page<Tasting> findByUserIdOrderByTastedOnDescCreatedAtDesc(UUID userId, Pageable pageable); | |
| 19 | + | |
| 20 | + List<Tasting> findTop6ByUserIdOrderByTastedOnDescCreatedAtDesc(UUID userId); | |
| 21 | + | |
| 22 | + long countByUserId(UUID userId); | |
| 23 | + | |
| 24 | + long countByUserIdAndPhotoUrl(UUID userId, String photoUrl); | |
| 25 | + | |
| 26 | + @Query("select distinct t.dishSlug from Tasting t where t.userId = :userId") | |
| 27 | + List<String> findDistinctDishSlugsByUserId(@Param("userId") UUID userId); | |
| 28 | + | |
| 29 | + @Query(""" | |
| 30 | + select distinct t.dishSlug from Tasting t | |
| 31 | + where t.userId = :userId | |
| 32 | + and t.countryCode = :countryCode | |
| 33 | + and t.tastedOn between :startsOn and :endsOn | |
| 34 | + """) | |
| 35 | + List<String> findDistinctDishSlugsDuring( | |
| 36 | + @Param("userId") UUID userId, | |
| 37 | + @Param("countryCode") String countryCode, | |
| 38 | + @Param("startsOn") LocalDate startsOn, | |
| 39 | + @Param("endsOn") LocalDate endsOn | |
| 40 | + ); | |
| 41 | + | |
| 42 | + @Query(""" | |
| 43 | + select distinct t.dishSlug from Tasting t | |
| 44 | + where t.userId = :userId | |
| 45 | + and t.tastedOn between :startsOn and :endsOn | |
| 46 | + """) | |
| 47 | + List<String> findDistinctDishSlugsBetween( | |
| 48 | + @Param("userId") UUID userId, | |
| 49 | + @Param("startsOn") LocalDate startsOn, | |
| 50 | + @Param("endsOn") LocalDate endsOn | |
| 51 | + ); | |
| 52 | +} |
added backend/src/main/java/com/tasteprint/tasting/TastingService.java +159 −0
| @@ -0,0 +1,159 @@ | ||
| 1 | +package com.tasteprint.tasting; | |
| 2 | + | |
| 3 | +import java.time.Clock; | |
| 4 | +import java.time.LocalDate; | |
| 5 | +import java.util.HashMap; | |
| 6 | +import java.util.HashSet; | |
| 7 | +import java.util.List; | |
| 8 | +import java.util.Locale; | |
| 9 | +import java.util.Map; | |
| 10 | +import java.util.Objects; | |
| 11 | +import java.util.Set; | |
| 12 | +import java.util.UUID; | |
| 13 | + | |
| 14 | +import org.springframework.context.ApplicationEventPublisher; | |
| 15 | +import org.springframework.data.domain.Page; | |
| 16 | +import org.springframework.data.domain.PageRequest; | |
| 17 | +import org.springframework.stereotype.Service; | |
| 18 | +import org.springframework.transaction.annotation.Transactional; | |
| 19 | + | |
| 20 | +import com.tasteprint.catalog.CatalogService; | |
| 21 | +import com.tasteprint.catalog.DishView; | |
| 22 | +import com.tasteprint.media.MediaStorage; | |
| 23 | +import com.tasteprint.shared.NotFoundException; | |
| 24 | + | |
| 25 | +@Service | |
| 26 | +public class TastingService { | |
| 27 | + | |
| 28 | + private final TastingRepository tastings; | |
| 29 | + private final CatalogService catalog; | |
| 30 | + private final ApplicationEventPublisher events; | |
| 31 | + private final MediaStorage mediaStorage; | |
| 32 | + private final Clock clock; | |
| 33 | + | |
| 34 | + TastingService(TastingRepository tastings, CatalogService catalog, | |
| 35 | + ApplicationEventPublisher events, MediaStorage mediaStorage, Clock clock) { | |
| 36 | + this.tastings = tastings; | |
| 37 | + this.catalog = catalog; | |
| 38 | + this.events = events; | |
| 39 | + this.mediaStorage = mediaStorage; | |
| 40 | + this.clock = clock; | |
| 41 | + } | |
| 42 | + | |
| 43 | + @Transactional | |
| 44 | + public TastingView record(UUID userId, SaveTastingRequest request) { | |
| 45 | + DishView dish = catalog.dish(request.dishSlug()); | |
| 46 | + mediaStorage.requireUsableBy(userId, request.photoUrl()); | |
| 47 | + String countryCode = normalizeCountryCode(request.countryCode()); | |
| 48 | + Tasting tasting = tastings.save(new Tasting( | |
| 49 | + UUID.randomUUID(), userId, request, countryCode, clock.instant() | |
| 50 | + )); | |
| 51 | + events.publishEvent(new TastingRecorded(tasting.id(), userId, tasting.dishSlug(), tasting.tastedOn())); | |
| 52 | + return view(tasting, dish); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Transactional | |
| 56 | + public TastingView update(UUID userId, UUID tastingId, SaveTastingRequest request) { | |
| 57 | + Tasting tasting = requiredOwned(tastingId, userId); | |
| 58 | + DishView dish = catalog.dish(request.dishSlug()); | |
| 59 | + mediaStorage.requireUsableBy(userId, request.photoUrl()); | |
| 60 | + String previousPhotoUrl = tasting.photoUrl(); | |
| 61 | + tasting.update(request, normalizeCountryCode(request.countryCode()), clock.instant()); | |
| 62 | + if (!Objects.equals(previousPhotoUrl, tasting.photoUrl())) { | |
| 63 | + tastings.flush(); | |
| 64 | + deletePhotoIfUnused(userId, previousPhotoUrl); | |
| 65 | + } | |
| 66 | + return view(tasting, dish); | |
| 67 | + } | |
| 68 | + | |
| 69 | + @Transactional | |
| 70 | + public void delete(UUID userId, UUID tastingId) { | |
| 71 | + Tasting tasting = requiredOwned(tastingId, userId); | |
| 72 | + String photoUrl = tasting.photoUrl(); | |
| 73 | + tastings.delete(tasting); | |
| 74 | + tastings.flush(); | |
| 75 | + deletePhotoIfUnused(userId, photoUrl); | |
| 76 | + } | |
| 77 | + | |
| 78 | + @Transactional(readOnly = true) | |
| 79 | + public TastingPage page(UUID userId, int page, int size) { | |
| 80 | + int safePage = Math.max(page, 0); | |
| 81 | + int safeSize = Math.min(Math.max(size, 1), 50); | |
| 82 | + Page<Tasting> result = tastings.findByUserIdOrderByTastedOnDescCreatedAtDesc( | |
| 83 | + userId, PageRequest.of(safePage, safeSize) | |
| 84 | + ); | |
| 85 | + List<TastingView> views = map(result.getContent()); | |
| 86 | + return new TastingPage( | |
| 87 | + views, safePage, safeSize, result.getTotalElements(), result.getTotalPages(), result.hasNext() | |
| 88 | + ); | |
| 89 | + } | |
| 90 | + | |
| 91 | + @Transactional(readOnly = true) | |
| 92 | + public List<TastingView> recent(UUID userId) { | |
| 93 | + return map(tastings.findTop6ByUserIdOrderByTastedOnDescCreatedAtDesc(userId)); | |
| 94 | + } | |
| 95 | + | |
| 96 | + @Transactional(readOnly = true) | |
| 97 | + public Set<String> triedDishSlugs(UUID userId) { | |
| 98 | + return new HashSet<>(tastings.findDistinctDishSlugsByUserId(userId)); | |
| 99 | + } | |
| 100 | + | |
| 101 | + @Transactional(readOnly = true) | |
| 102 | + public Set<String> triedDishSlugsDuring(UUID userId, String countryCode, | |
| 103 | + LocalDate startsOn, LocalDate endsOn) { | |
| 104 | + return new HashSet<>(tastings.findDistinctDishSlugsDuring( | |
| 105 | + userId, normalizeCountryCode(countryCode), startsOn, endsOn | |
| 106 | + )); | |
| 107 | + } | |
| 108 | + | |
| 109 | + @Transactional(readOnly = true) | |
| 110 | + public Set<String> triedDishSlugsBetween(UUID userId, LocalDate startsOn, LocalDate endsOn) { | |
| 111 | + return new HashSet<>(tastings.findDistinctDishSlugsBetween(userId, startsOn, endsOn)); | |
| 112 | + } | |
| 113 | + | |
| 114 | + @Transactional(readOnly = true) | |
| 115 | + public long count(UUID userId) { | |
| 116 | + return tastings.countByUserId(userId); | |
| 117 | + } | |
| 118 | + | |
| 119 | + @Transactional(readOnly = true) | |
| 120 | + public boolean hasAny(UUID userId) { | |
| 121 | + return tastings.countByUserId(userId) > 0; | |
| 122 | + } | |
| 123 | + | |
| 124 | + private List<TastingView> map(List<Tasting> entities) { | |
| 125 | + Map<String, DishView> dishBySlug = new HashMap<>(); | |
| 126 | + for (Tasting tasting : entities) { | |
| 127 | + dishBySlug.computeIfAbsent(tasting.dishSlug(), catalog::dish); | |
| 128 | + } | |
| 129 | + return entities.stream() | |
| 130 | + .map(tasting -> view(tasting, dishBySlug.get(tasting.dishSlug()))) | |
| 131 | + .toList(); | |
| 132 | + } | |
| 133 | + | |
| 134 | + private Tasting requiredOwned(UUID tastingId, UUID userId) { | |
| 135 | + return tastings.findByIdAndUserId(tastingId, userId) | |
| 136 | + .orElseThrow(() -> new NotFoundException("Tasting was not found.")); | |
| 137 | + } | |
| 138 | + | |
| 139 | + private String normalizeCountryCode(String countryCode) { | |
| 140 | + if (countryCode == null || !countryCode.matches("(?i)[a-z]{2}")) { | |
| 141 | + throw new IllegalArgumentException("Country must be a two-letter code."); | |
| 142 | + } | |
| 143 | + return countryCode.toUpperCase(Locale.ROOT); | |
| 144 | + } | |
| 145 | + | |
| 146 | + private void deletePhotoIfUnused(UUID userId, String photoUrl) { | |
| 147 | + if (photoUrl != null && tastings.countByUserIdAndPhotoUrl(userId, photoUrl) == 0) { | |
| 148 | + mediaStorage.deleteOwned(userId, photoUrl); | |
| 149 | + } | |
| 150 | + } | |
| 151 | + | |
| 152 | + private TastingView view(Tasting tasting, DishView dish) { | |
| 153 | + return new TastingView( | |
| 154 | + tasting.id(), dish, tasting.restaurantName(), tasting.city(), tasting.countryCode(), | |
| 155 | + tasting.tastedOn(), tasting.rating(), tasting.note(), tasting.photoUrl(), tasting.latitude(), | |
| 156 | + tasting.longitude(), tasting.createdAt() | |
| 157 | + ); | |
| 158 | + } | |
| 159 | +} |
added backend/src/main/java/com/tasteprint/tasting/TastingView.java +23 −0
| @@ -0,0 +1,23 @@ | ||
| 1 | +package com.tasteprint.tasting; | |
| 2 | + | |
| 3 | +import java.time.Instant; | |
| 4 | +import java.time.LocalDate; | |
| 5 | +import java.util.UUID; | |
| 6 | + | |
| 7 | +import com.tasteprint.catalog.DishView; | |
| 8 | + | |
| 9 | +public record TastingView( | |
| 10 | + UUID id, | |
| 11 | + DishView dish, | |
| 12 | + String restaurantName, | |
| 13 | + String city, | |
| 14 | + String countryCode, | |
| 15 | + LocalDate tastedOn, | |
| 16 | + int rating, | |
| 17 | + String note, | |
| 18 | + String photoUrl, | |
| 19 | + Double latitude, | |
| 20 | + Double longitude, | |
| 21 | + Instant createdAt | |
| 22 | +) { | |
| 23 | +} |
added backend/src/main/java/com/tasteprint/tasting/package-info.java +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +@org.springframework.modulith.ApplicationModule( | |
| 2 | + displayName = "Tasting log", | |
| 3 | + allowedDependencies = {"account", "catalog", "media", "shared"} | |
| 4 | +) | |
| 5 | +package com.tasteprint.tasting; |
added backend/src/main/resources/application.yml +44 −0
| @@ -0,0 +1,44 @@ | ||
| 1 | +spring: | |
| 2 | + application: | |
| 3 | + name: tasteprint | |
| 4 | + datasource: | |
| 5 | + url: ${DB_URL:jdbc:h2:file:./data/tasteprint;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH} | |
| 6 | + username: ${DB_USER:sa} | |
| 7 | + password: ${DB_PASSWORD:} | |
| 8 | + flyway: | |
| 9 | + enabled: true | |
| 10 | + jpa: | |
| 11 | + hibernate: | |
| 12 | + ddl-auto: validate | |
| 13 | + open-in-view: false | |
| 14 | + properties: | |
| 15 | + hibernate: | |
| 16 | + jdbc: | |
| 17 | + time_zone: UTC | |
| 18 | + servlet: | |
| 19 | + multipart: | |
| 20 | + max-file-size: 6MB | |
| 21 | + max-request-size: 6MB | |
| 22 | + | |
| 23 | +server: | |
| 24 | + port: ${PORT:8080} | |
| 25 | + forward-headers-strategy: framework | |
| 26 | + | |
| 27 | +management: | |
| 28 | + endpoints: | |
| 29 | + web: | |
| 30 | + exposure: | |
| 31 | + include: health,info | |
| 32 | + endpoint: | |
| 33 | + health: | |
| 34 | + probes: | |
| 35 | + enabled: true | |
| 36 | + | |
| 37 | +springdoc: | |
| 38 | + swagger-ui: | |
| 39 | + path: /docs | |
| 40 | + | |
| 41 | +app: | |
| 42 | + allowed-origins: ${APP_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000} | |
| 43 | + media-directory: ${MEDIA_DIRECTORY:./uploads} | |
| 44 | + demo-data-enabled: ${DEMO_DATA_ENABLED:true} |
added backend/src/main/resources/db/migration/V1__create_schema.sql +127 −0
| @@ -0,0 +1,127 @@ | ||
| 1 | +create table app_user ( | |
| 2 | + id uuid primary key, | |
| 3 | + display_name varchar(80) not null, | |
| 4 | + email varchar(254) not null unique, | |
| 5 | + password_hash varchar(100) not null, | |
| 6 | + share_slug varchar(100) not null unique, | |
| 7 | + home_city varchar(100), | |
| 8 | + home_country_code varchar(2), | |
| 9 | + bio varchar(280), | |
| 10 | + avatar_url varchar(500), | |
| 11 | + profile_public boolean not null default false, | |
| 12 | + created_at timestamp with time zone not null, | |
| 13 | + updated_at timestamp with time zone not null | |
| 14 | +); | |
| 15 | + | |
| 16 | +create table session_token ( | |
| 17 | + id uuid primary key, | |
| 18 | + user_id uuid not null references app_user(id) on delete cascade, | |
| 19 | + token_hash varchar(64) not null unique, | |
| 20 | + expires_at timestamp with time zone not null, | |
| 21 | + created_at timestamp with time zone not null | |
| 22 | +); | |
| 23 | + | |
| 24 | +create index idx_session_token_user on session_token(user_id); | |
| 25 | +create index idx_session_token_expiry on session_token(expires_at); | |
| 26 | + | |
| 27 | +create table destination ( | |
| 28 | + code varchar(2) primary key, | |
| 29 | + name varchar(100) not null, | |
| 30 | + local_name varchar(100) not null, | |
| 31 | + region_name varchar(100) not null, | |
| 32 | + summary varchar(500) not null, | |
| 33 | + center_lat double precision not null, | |
| 34 | + center_lng double precision not null, | |
| 35 | + accent_color varchar(7) not null, | |
| 36 | + display_order integer not null | |
| 37 | +); | |
| 38 | + | |
| 39 | +create table dish ( | |
| 40 | + slug varchar(120) primary key, | |
| 41 | + destination_code varchar(2) not null references destination(code), | |
| 42 | + name varchar(140) not null, | |
| 43 | + local_name varchar(140), | |
| 44 | + category varchar(30) not null, | |
| 45 | + description varchar(500) not null, | |
| 46 | + why_it_matters varchar(500) not null, | |
| 47 | + importance smallint not null, | |
| 48 | + image_url varchar(500), | |
| 49 | + vegetarian boolean not null, | |
| 50 | + spicy_level smallint not null, | |
| 51 | + display_order integer not null, | |
| 52 | + constraint chk_dish_importance check (importance between 1 and 5), | |
| 53 | + constraint chk_dish_spicy_level check (spicy_level between 0 and 3) | |
| 54 | +); | |
| 55 | + | |
| 56 | +create index idx_dish_destination on dish(destination_code, display_order); | |
| 57 | + | |
| 58 | +create table tasting ( | |
| 59 | + id uuid primary key, | |
| 60 | + user_id uuid not null references app_user(id) on delete cascade, | |
| 61 | + dish_slug varchar(120) not null references dish(slug), | |
| 62 | + restaurant_name varchar(160), | |
| 63 | + city varchar(100) not null, | |
| 64 | + country_code varchar(2) not null, | |
| 65 | + tasted_on date not null, | |
| 66 | + rating smallint not null, | |
| 67 | + note varchar(500), | |
| 68 | + photo_url varchar(500), | |
| 69 | + latitude double precision, | |
| 70 | + longitude double precision, | |
| 71 | + created_at timestamp with time zone not null, | |
| 72 | + updated_at timestamp with time zone not null, | |
| 73 | + constraint chk_tasting_rating check (rating between 1 and 5), | |
| 74 | + constraint chk_tasting_coordinates check ( | |
| 75 | + (latitude is null and longitude is null) | |
| 76 | + or (latitude between -90 and 90 and longitude between -180 and 180) | |
| 77 | + ) | |
| 78 | +); | |
| 79 | + | |
| 80 | +create index idx_tasting_user_date on tasting(user_id, tasted_on desc); | |
| 81 | +create index idx_tasting_user_dish on tasting(user_id, dish_slug); | |
| 82 | +create index idx_tasting_trip_match on tasting(user_id, country_code, tasted_on); | |
| 83 | + | |
| 84 | +create table trip ( | |
| 85 | + id uuid primary key, | |
| 86 | + user_id uuid not null references app_user(id) on delete cascade, | |
| 87 | + destination_code varchar(2) not null references destination(code), | |
| 88 | + city varchar(100) not null, | |
| 89 | + starts_on date not null, | |
| 90 | + ends_on date not null, | |
| 91 | + created_at timestamp with time zone not null, | |
| 92 | + updated_at timestamp with time zone not null, | |
| 93 | + constraint chk_trip_dates check (ends_on >= starts_on) | |
| 94 | +); | |
| 95 | + | |
| 96 | +create index idx_trip_user_dates on trip(user_id, starts_on, ends_on); | |
| 97 | + | |
| 98 | +create table trip_mission_item ( | |
| 99 | + id uuid primary key, | |
| 100 | + trip_id uuid not null references trip(id) on delete cascade, | |
| 101 | + dish_slug varchar(120) not null references dish(slug), | |
| 102 | + position smallint not null, | |
| 103 | + unique(trip_id, dish_slug), | |
| 104 | + unique(trip_id, position) | |
| 105 | +); | |
| 106 | + | |
| 107 | +create table taste_challenge ( | |
| 108 | + id uuid primary key, | |
| 109 | + owner_id uuid not null references app_user(id), | |
| 110 | + title varchar(100) not null, | |
| 111 | + destination_code varchar(2) not null references destination(code), | |
| 112 | + join_code varchar(8) not null unique, | |
| 113 | + starts_on date not null, | |
| 114 | + ends_on date not null, | |
| 115 | + created_at timestamp with time zone not null, | |
| 116 | + constraint chk_challenge_dates check (ends_on >= starts_on) | |
| 117 | +); | |
| 118 | + | |
| 119 | +create table challenge_participant ( | |
| 120 | + id uuid primary key, | |
| 121 | + challenge_id uuid not null references taste_challenge(id) on delete cascade, | |
| 122 | + user_id uuid not null references app_user(id) on delete cascade, | |
| 123 | + joined_at timestamp with time zone not null, | |
| 124 | + unique(challenge_id, user_id) | |
| 125 | +); | |
| 126 | + | |
| 127 | +create index idx_challenge_participant_user on challenge_participant(user_id); |
added backend/src/main/resources/db/migration/V2__seed_culinary_catalog.sql +98 −0
| @@ -0,0 +1,98 @@ | ||
| 1 | +insert into destination (code, name, local_name, region_name, summary, center_lat, center_lng, accent_color, display_order) values | |
| 2 | +('JP', 'Japan', '日本', 'East Asia', 'A food culture shaped by seasonality, precision, preservation, and strong regional identities.', 36.2048, 138.2529, '#E84749', 1), | |
| 3 | +('MX', 'Mexico', 'México', 'North America', 'Maize, chiles, beans, herbs, and regional techniques form one of the world''s deepest everyday food traditions.', 23.6345, -102.5528, '#E96A2C', 2), | |
| 4 | +('PT', 'Portugal', 'Portugal', 'Southern Europe', 'Atlantic seafood, preserved cod, broths, small sandwiches, coffee, and convent sweets.', 39.3999, -8.2245, '#2878A5', 3), | |
| 5 | +('GE', 'Georgia', 'საქართველო', 'Caucasus', 'Bread, cheese, walnuts, herbs, dumplings, and ancient wine traditions meet around a shared table.', 42.3154, 43.3569, '#A6503B', 4), | |
| 6 | +('TH', 'Thailand', 'ประเทศไทย', 'Southeast Asia', 'Sweet, sour, salty, spicy, and bitter notes are balanced across street stalls and regional kitchens.', 15.8700, 100.9925, '#7258B8', 5), | |
| 7 | +('VN', 'Vietnam', 'Việt Nam', 'Southeast Asia', 'Fresh herbs, broths, rice, grilled meats, and strong coffee change distinctly from north to south.', 14.0583, 108.2772, '#2D8A68', 6), | |
| 8 | +('MA', 'Morocco', 'المغرب', 'North Africa', 'Slow cooking, grains, preserved citrus, warm spices, tea, and communal eating define the table.', 31.7917, -7.0926, '#C7543A', 7), | |
| 9 | +('PE', 'Peru', 'Perú', 'South America', 'Andean produce, Pacific seafood, Indigenous traditions, and migration created a highly varied cuisine.', -9.1900, -75.0152, '#D84545', 8), | |
| 10 | +('IT', 'Italy', 'Italia', 'Southern Europe', 'Local ingredients and regional identity matter more than a single national menu.', 41.8719, 12.5674, '#3A8A63', 9), | |
| 11 | +('IN', 'India', 'भारत', 'South Asia', 'A vast collection of regional cuisines connected by layered spice, grains, legumes, and varied techniques.', 20.5937, 78.9629, '#D9822B', 10), | |
| 12 | +('EE', 'Estonia', 'Eesti', 'Northern Europe', 'Seasonal forest ingredients, rye, dairy, preserved fish, grains, and modern Nordic influences.', 58.5953, 25.0136, '#3B76B8', 11), | |
| 13 | +('KR', 'South Korea', '대한민국', 'East Asia', 'Fermentation, shared side dishes, rice, soups, grills, and lively street food shape daily eating.', 35.9078, 127.7669, '#8B4DA8', 12); | |
| 14 | + | |
| 15 | +insert into dish (slug, destination_code, name, local_name, category, description, why_it_matters, importance, image_url, vegetarian, spicy_level, display_order) values | |
| 16 | +('ramen', 'JP', 'Ramen', 'ラーメン', 'STAPLE', 'Wheat noodles served in a carefully built broth with regional toppings.', 'Its broths and styles reveal strong local identities, from Sapporo miso to Hakata tonkotsu.', 5, null, false, 1, 1), | |
| 17 | +('takoyaki', 'JP', 'Takoyaki', 'たこ焼き', 'STREET', 'Griddled batter balls filled with octopus and finished with sauce and bonito.', 'A defining Osaka street snack built around speed, technique, and communal eating.', 4, null, false, 0, 2), | |
| 18 | +('japanese-breakfast', 'JP', 'Japanese breakfast', '朝ご飯', 'BREAKFAST', 'Rice, miso soup, pickles, fish, and small sides served as a balanced morning meal.', 'It shows the structure of an everyday Japanese meal better than a single famous dish.', 4, null, false, 0, 3), | |
| 19 | +('daifuku', 'JP', 'Daifuku', '大福', 'SWEET', 'Soft mochi wrapped around a sweet filling, commonly red bean paste.', 'It introduces the texture and restrained sweetness central to wagashi.', 3, null, true, 0, 4), | |
| 20 | +('matcha', 'JP', 'Matcha', '抹茶', 'DRINK', 'Finely milled green tea whisked with hot water.', 'Its preparation connects everyday taste with a long ceremonial tradition.', 3, null, true, 0, 5), | |
| 21 | +('sushi', 'JP', 'Sushi', '寿司', 'SIGNATURE', 'Seasoned rice paired with seafood, vegetables, egg, or preserved ingredients.', 'The rice, seasonality, knife work, and many regional forms make it more than raw fish.', 5, null, false, 0, 6), | |
| 22 | + | |
| 23 | +('mole-poblano', 'MX', 'Mole poblano', 'Mole poblano', 'STAPLE', 'A layered chile sauce with spices, seeds, nuts, and sometimes cacao, commonly served with poultry.', 'It represents celebration cooking and the patience of building flavor in stages.', 5, null, false, 2, 1), | |
| 24 | +('tacos-al-pastor', 'MX', 'Tacos al pastor', 'Tacos al pastor', 'STREET', 'Marinated pork shaved from a vertical spit into small maize tortillas.', 'The dish shows how Lebanese migration and Mexican street cooking formed something new.', 5, null, false, 2, 2), | |
| 25 | +('chilaquiles', 'MX', 'Chilaquiles', 'Chilaquiles', 'BREAKFAST', 'Tortilla pieces simmered briefly in salsa and topped with crema, cheese, onion, and egg or meat.', 'A practical breakfast that makes yesterday''s tortillas the center of the meal.', 4, null, true, 2, 3), | |
| 26 | +('churros', 'MX', 'Churros', 'Churros', 'SWEET', 'Fried ridged dough coated with sugar and often served with chocolate or caramel.', 'They are an accessible entry into Mexico''s street-side sweet culture.', 3, null, true, 0, 4), | |
| 27 | +('agua-fresca', 'MX', 'Agua fresca', 'Agua fresca', 'DRINK', 'Fruit, seeds, flowers, or grains blended with water and a little sugar.', 'Flavors such as jamaica and horchata are part of everyday markets and lunch counters.', 3, null, true, 0, 5), | |
| 28 | +('pozole', 'MX', 'Pozole', 'Pozole', 'SIGNATURE', 'A hominy stew served with crisp vegetables, lime, chile, and regional meat or vegetarian bases.', 'Its Indigenous roots and role in gatherings make it culturally central.', 5, null, false, 2, 6), | |
| 29 | + | |
| 30 | +('bacalhau-a-bras', 'PT', 'Bacalhau à Brás', 'Bacalhau à Brás', 'STAPLE', 'Salt cod folded through thin potatoes, onion, and softly set egg.', 'It shows how preserved cod became part of everyday Portuguese cooking far from the fishing grounds.', 5, null, false, 0, 1), | |
| 31 | +('bifana', 'PT', 'Bifana', 'Bifana', 'STREET', 'Thin marinated pork in a crusty bread roll, often sharpened with mustard or hot sauce.', 'This small sandwich is a direct taste of cafés, markets, and late-night counters.', 4, null, false, 1, 2), | |
| 32 | +('pao-galao', 'PT', 'Pão com manteiga and galão', 'Pão com manteiga e galão', 'BREAKFAST', 'Buttered bread with a tall glass of milky coffee.', 'The simple pairing reflects the daily rhythm of the Portuguese neighbourhood café.', 3, null, true, 0, 3), | |
| 33 | +('pastel-de-nata', 'PT', 'Pastel de nata', 'Pastel de nata', 'SWEET', 'A blistered puff pastry tart filled with baked egg custard.', 'It links convent baking traditions with one of Portugal''s most recognisable café rituals.', 5, null, true, 0, 4), | |
| 34 | +('vinho-verde', 'PT', 'Vinho Verde', 'Vinho Verde', 'DRINK', 'Young, bright wine from northwestern Portugal, ranging from white to red and rosé.', 'It expresses a protected region rather than a grape or simply a green-coloured wine.', 3, null, true, 0, 5), | |
| 35 | +('caldo-verde', 'PT', 'Caldo verde', 'Caldo verde', 'SIGNATURE', 'A smooth potato and onion soup threaded with finely cut greens.', 'The humble soup appears at home, celebrations, and late-night gatherings across Portugal.', 4, null, false, 0, 6), | |
| 36 | + | |
| 37 | +('lobio', 'GE', 'Lobio', 'ლობიო', 'STAPLE', 'Slow-cooked beans seasoned with herbs and spices, often served in a clay pot.', 'It shows the depth of Georgia''s vegetable and pulse cooking beside its better-known breads and meat.', 4, null, true, 1, 1), | |
| 38 | +('khachapuri', 'GE', 'Khachapuri', 'ხაჭაპური', 'STREET', 'Cheese-filled bread with shapes and fillings that vary by region.', 'Comparing Imeretian, Adjarian, and other forms is a map of Georgia in bread.', 5, null, true, 0, 2), | |
| 39 | +('khashi', 'GE', 'Khashi', 'ხაში', 'BREAKFAST', 'A restorative garlic-rich soup traditionally eaten early in the morning.', 'Its timing, rituals, and social setting reveal a side of Georgian eating missed by restaurant highlights.', 3, null, false, 0, 3), | |
| 40 | +('churchkhela', 'GE', 'Churchkhela', 'ჩურჩხელა', 'SWEET', 'Nuts threaded on a string and repeatedly dipped in thickened grape juice.', 'The portable sweet connects grape harvest, preservation, and household craft.', 4, null, true, 0, 4), | |
| 41 | +('amber-wine', 'GE', 'Qvevri amber wine', 'ქვევრის ღვინო', 'DRINK', 'White grapes fermented with skins in buried clay vessels called qvevri.', 'Georgia''s qvevri tradition is one of the world''s oldest continuous winemaking methods.', 5, null, true, 0, 5), | |
| 42 | +('khinkali', 'GE', 'Khinkali', 'ხინკალი', 'SIGNATURE', 'Pleated dumplings filled with seasoned meat, mushrooms, potato, or cheese.', 'Eating the broth-filled dumpling by hand is as important as its filling.', 5, null, false, 1, 6), | |
| 43 | + | |
| 44 | +('pad-kra-pao', 'TH', 'Pad kra pao', 'ผัดกะเพรา', 'STAPLE', 'A fast stir-fry of holy basil, chile, garlic, and protein over rice.', 'It is a common made-to-order meal that shows the direct force of Thai wok cooking.', 5, null, false, 3, 1), | |
| 45 | +('moo-ping', 'TH', 'Moo ping', 'หมูปิ้ง', 'STREET', 'Sweet-savoury pork skewers grilled over charcoal and commonly eaten with sticky rice.', 'The smoke, marinade, and portability make it a morning and street-market staple.', 4, null, false, 1, 2), | |
| 46 | +('jok', 'TH', 'Jok', 'โจ๊ก', 'BREAKFAST', 'Soft rice porridge topped with pork, egg, ginger, and fresh herbs.', 'It shows the gentle, comforting side of Thai flavour beyond chile-forward dishes.', 3, null, false, 0, 3), | |
| 47 | +('mango-sticky-rice', 'TH', 'Mango sticky rice', 'ข้าวเหนียวมะม่วง', 'SWEET', 'Glutinous rice enriched with coconut milk and served with ripe mango.', 'The dish depends on fruit season, rice texture, and the balance of salt and sweetness.', 4, null, true, 0, 4), | |
| 48 | +('cha-yen', 'TH', 'Thai iced tea', 'ชาเย็น', 'DRINK', 'Strong brewed tea poured over ice with sweetened milk.', 'Its colour, sweetness, and cooling role make it part of modern street-food life.', 3, null, true, 0, 5), | |
| 49 | +('tom-yum-goong', 'TH', 'Tom yum goong', 'ต้มยำกุ้ง', 'SIGNATURE', 'A hot and sour prawn soup scented with lemongrass, galangal, lime leaf, and chile.', 'It demonstrates the balance and aromatic layering at the heart of Thai cooking.', 5, null, false, 3, 6), | |
| 50 | + | |
| 51 | +('com-tam', 'VN', 'Cơm tấm', 'Cơm tấm', 'STAPLE', 'Broken rice served with grilled pork, pickles, scallion oil, and fish sauce.', 'Once a thrifty use of fractured grains, it became a defining southern everyday plate.', 4, null, false, 1, 1), | |
| 52 | +('banh-mi', 'VN', 'Bánh mì', 'Bánh mì', 'STREET', 'A crisp baguette filled with pâté, meats or tofu, pickles, herbs, and chile.', 'It carries the history of French colonial bread transformed by Vietnamese ingredients.', 5, null, false, 2, 2), | |
| 53 | +('pho', 'VN', 'Phở', 'Phở', 'BREAKFAST', 'Rice noodles in an aromatic broth with herbs and sliced meat.', 'Often eaten in the morning, its northern and southern forms expose regional differences.', 5, null, false, 1, 3), | |
| 54 | +('che-ba-mau', 'VN', 'Chè ba màu', 'Chè ba màu', 'SWEET', 'A layered dessert of beans, jelly, coconut milk, and crushed ice.', 'It introduces chè as a broad Vietnamese category rather than one fixed dessert.', 3, null, true, 0, 4), | |
| 55 | +('ca-phe-sua-da', 'VN', 'Cà phê sữa đá', 'Cà phê sữa đá', 'DRINK', 'Dark drip coffee mixed with condensed milk and poured over ice.', 'The drink reflects Vietnam''s coffee-growing history and slow streetside café culture.', 4, null, true, 0, 5), | |
| 56 | +('bun-bo-hue', 'VN', 'Bún bò Huế', 'Bún bò Huế', 'SIGNATURE', 'A lemongrass-scented spicy noodle soup associated with the former imperial city of Huế.', 'It expands the traveller''s view beyond phở and highlights central Vietnamese cuisine.', 5, null, false, 3, 6), | |
| 57 | + | |
| 58 | +('couscous', 'MA', 'Couscous', 'كسكس', 'STAPLE', 'Steamed semolina grains served with vegetables, broth, and often meat.', 'Friday couscous is a shared family meal and a key expression of Amazigh food heritage.', 5, null, false, 1, 1), | |
| 59 | +('msemen', 'MA', 'Msemen', 'مسمن', 'STREET', 'A layered square flatbread cooked on a griddle and served sweet or savoury.', 'It moves easily between breakfast tables, tea stalls, and street snacks.', 4, null, true, 0, 2), | |
| 60 | +('bissara', 'MA', 'Bissara', 'بيصارة', 'BREAKFAST', 'A thick fava bean or split pea soup finished with olive oil, cumin, and chile.', 'This inexpensive breakfast shows Morocco''s everyday pulse-based cooking.', 3, null, true, 1, 3), | |
| 61 | +('chebakia', 'MA', 'Chebakia', 'الشباكية', 'SWEET', 'Flower-shaped fried pastry coated with honey and sesame.', 'It is closely associated with Ramadan tables and the soup harira.', 3, null, true, 0, 4), | |
| 62 | +('mint-tea', 'MA', 'Moroccan mint tea', 'أتاي بالنعناع', 'DRINK', 'Green tea brewed with mint and sugar, poured from height into small glasses.', 'Preparation and service express hospitality as much as refreshment.', 5, null, true, 0, 5), | |
| 63 | +('tagine', 'MA', 'Tagine', 'طاجين', 'SIGNATURE', 'A slow-cooked stew named for its conical earthenware vessel.', 'Regional combinations of meat, vegetables, fruit, and preserved lemon reward looking beyond one recipe.', 5, null, false, 1, 6), | |
| 64 | + | |
| 65 | +('lomo-saltado', 'PE', 'Lomo saltado', 'Lomo saltado', 'STAPLE', 'Beef, onion, tomato, and chile stir-fried with soy sauce and served with rice and fries.', 'The dish makes Chinese Peruvian chifa influence visible on one plate.', 5, null, false, 1, 1), | |
| 66 | +('anticuchos', 'PE', 'Anticuchos', 'Anticuchos', 'STREET', 'Marinated beef heart skewers grilled over high heat with chile and spices.', 'They connect pre-Columbian ingredients, colonial history, and modern street cooking.', 5, null, false, 2, 2), | |
| 67 | +('pan-con-chicharron', 'PE', 'Pan con chicharrón', 'Pan con chicharrón', 'BREAKFAST', 'A roll filled with fried pork, sweet potato, and sharp onion relish.', 'The mix of rich, sweet, and acidic flavours is a classic weekend breakfast.', 4, null, false, 1, 3), | |
| 68 | +('picarones', 'PE', 'Picarones', 'Picarones', 'SWEET', 'Squash and sweet-potato rings fried and served with spiced syrup.', 'They are a distinctly Peruvian relative of the doughnut with Indigenous ingredients.', 4, null, true, 0, 4), | |
| 69 | +('chicha-morada', 'PE', 'Chicha morada', 'Chicha morada', 'DRINK', 'A purple-corn drink simmered with fruit and spices and served chilled.', 'Its colour and core ingredient come directly from Andean biodiversity.', 4, null, true, 0, 5), | |
| 70 | +('ceviche', 'PE', 'Ceviche', 'Ceviche', 'SIGNATURE', 'Fresh fish cured briefly in lime with chile, onion, and coriander.', 'Timing, fish quality, leche de tigre, and regional sides make it central to Peru''s coastal identity.', 5, null, false, 2, 6), | |
| 71 | + | |
| 72 | +('pasta-carbonara', 'IT', 'Pasta carbonara', 'Pasta alla carbonara', 'STAPLE', 'Pasta coated with egg, pecorino, cured pork, and black pepper.', 'Its short ingredient list makes Roman technique and ingredient choices impossible to hide.', 5, null, false, 0, 1), | |
| 73 | +('arancini', 'IT', 'Arancini', 'Arancini', 'STREET', 'Breaded and fried rice parcels with regional fillings.', 'Names, shapes, and fillings reveal local identity across Sicily.', 4, null, false, 0, 2), | |
| 74 | +('cornetto-cappuccino', 'IT', 'Cornetto and cappuccino', 'Cornetto e cappuccino', 'BREAKFAST', 'A filled or plain pastry paired with milk coffee at the bar.', 'The compact ritual explains the pace and social rules of the Italian morning café.', 4, null, true, 0, 3), | |
| 75 | +('tiramisu', 'IT', 'Tiramisù', 'Tiramisù', 'SWEET', 'Coffee-soaked biscuits layered with mascarpone cream and cocoa.', 'Its modern origin and many regional claims make it part dessert and part identity debate.', 4, null, true, 0, 4), | |
| 76 | +('espresso', 'IT', 'Espresso', 'Caffè espresso', 'DRINK', 'A small concentrated coffee commonly taken standing at the bar.', 'Ordering and drinking it in context is a daily social ritual, not only a brewing method.', 4, null, true, 0, 5), | |
| 77 | +('pizza-napoletana', 'IT', 'Neapolitan pizza', 'Pizza napoletana', 'SIGNATURE', 'Soft, blistered dough baked quickly at high heat with restrained toppings.', 'The dough, oven, and Neapolitan craft distinguish it from the global category called pizza.', 5, null, true, 0, 6), | |
| 78 | + | |
| 79 | +('dal-tadka', 'IN', 'Dal tadka', 'दाल तड़का', 'STAPLE', 'Cooked lentils finished with a hot tempering of spices and aromatics.', 'Dal shows how pulses, texture, and tempering techniques underpin daily meals across many regions.', 4, null, true, 1, 1), | |
| 80 | +('pani-puri', 'IN', 'Pani puri', 'पानी पूरी', 'STREET', 'Crisp hollow shells filled with potato, chickpeas, chutney, and spiced water.', 'Its many regional names and variations make comparison part of the experience.', 5, null, true, 3, 2), | |
| 81 | +('masala-dosa', 'IN', 'Masala dosa', 'मसाला डोसा', 'BREAKFAST', 'A fermented rice and lentil crêpe wrapped around spiced potato.', 'This southern breakfast demonstrates fermentation, griddle technique, and a full set of accompaniments.', 5, null, true, 2, 3), | |
| 82 | +('gulab-jamun', 'IN', 'Gulab jamun', 'गुलाब जामुन', 'SWEET', 'Fried milk-solid dumplings soaked in fragrant sugar syrup.', 'It is a widely recognised celebration sweet while still varying by household and region.', 3, null, true, 0, 4), | |
| 83 | +('masala-chai', 'IN', 'Masala chai', 'मसाला चाय', 'DRINK', 'Black tea simmered with milk, sugar, and a changing mix of spices.', 'The small glass or cup belongs to daily breaks, stations, homes, and roadside stalls.', 4, null, true, 1, 5), | |
| 84 | +('biryani', 'IN', 'Biryani', 'बिरयानी', 'SIGNATURE', 'Layered or cooked-together rice with aromatics, spices, and meat or vegetables.', 'Hyderabadi, Lucknowi, Kolkata, and other biryanis show why India cannot be reduced to one flavour profile.', 5, null, false, 2, 6), | |
| 85 | + | |
| 86 | +('mulgipuder', 'EE', 'Mulgipuder', 'Mulgipuder', 'STAPLE', 'Mashed potatoes and barley groats traditionally served with pork and onion.', 'The farmhouse dish ties southern Estonian regional identity to filling grain-and-potato cooking.', 4, null, false, 0, 1), | |
| 87 | +('kiluleib', 'EE', 'Sprat sandwich', 'Kiluvõileib', 'STREET', 'Dark rye bread topped with spiced sprat, egg, and fresh onion or herbs.', 'Its compact combination of rye and Baltic fish is an immediate Estonian flavour marker.', 5, null, false, 0, 2), | |
| 88 | +('kama', 'EE', 'Kama', 'Kama', 'BREAKFAST', 'A roasted grain and pea flour mixture stirred into kefir, yoghurt, or buttermilk.', 'The preserved flour began as practical travel food and remains distinctively Estonian.', 5, null, true, 0, 3), | |
| 89 | +('kohuke', 'EE', 'Kohuke', 'Kohuke', 'SWEET', 'A small sweet curd snack usually coated in chocolate or glaze.', 'It is a familiar everyday dairy snack across generations, not a restaurant dessert.', 4, null, true, 0, 4), | |
| 90 | +('kali', 'EE', 'Kali', 'Kali', 'DRINK', 'A lightly fermented, low-alcohol or alcohol-free drink made from rye bread or malt.', 'Its malty tartness connects bread culture with a traditional everyday drink.', 3, null, true, 0, 5), | |
| 91 | +('verivorst', 'EE', 'Blood sausage', 'Verivorst', 'SIGNATURE', 'Barley blood sausage served with sauerkraut, potatoes, and lingonberry preserve.', 'The winter and Christmas context matters as much as the sausage itself.', 5, null, false, 0, 6), | |
| 92 | + | |
| 93 | +('bibimbap', 'KR', 'Bibimbap', '비빔밥', 'STAPLE', 'Rice mixed with seasoned vegetables, sauce, egg, and optional meat.', 'The act of mixing varied prepared components reflects balance within one bowl.', 4, null, false, 2, 1), | |
| 94 | +('tteokbokki', 'KR', 'Tteokbokki', '떡볶이', 'STREET', 'Chewy rice cakes cooked in a glossy, often spicy gochujang sauce.', 'It is a defining modern snack-shop and market food with many new variations.', 5, null, true, 3, 2), | |
| 95 | +('gukbap', 'KR', 'Gukbap', '국밥', 'BREAKFAST', 'Hot soup served with rice, often built around beef, pork, or offal.', 'Regional bowls show the restorative, practical side of Korean morning and late-night eating.', 3, null, false, 1, 3), | |
| 96 | +('hotteok', 'KR', 'Hotteok', '호떡', 'SWEET', 'Pan-fried filled pancakes, commonly with brown sugar, seeds, and nuts.', 'The crisp-chewy winter snack is best understood fresh from a street griddle.', 4, null, true, 0, 4), | |
| 97 | +('makgeolli', 'KR', 'Makgeolli', '막걸리', 'DRINK', 'A cloudy lightly sparkling rice alcohol with a soft tang.', 'Its farming history and pairing with savoury pancakes make drinking context important.', 4, null, true, 0, 5), | |
| 98 | +('kimchi', 'KR', 'Kimchi', '김치', 'SIGNATURE', 'Vegetables salted and fermented with seasonings, with hundreds of regional and seasonal forms.', 'Kimchi is a technique, side dish, preservation system, and shared cultural practice.', 5, null, true, 3, 6); |
added backend/src/main/resources/db/migration/V3__track_media_ownership.sql +11 −0
| @@ -0,0 +1,11 @@ | ||
| 1 | +create table media_asset ( | |
| 2 | + id uuid primary key, | |
| 3 | + owner_id uuid not null references app_user(id) on delete cascade, | |
| 4 | + filename varchar(80) not null unique, | |
| 5 | + content_type varchar(40) not null, | |
| 6 | + size_bytes bigint not null, | |
| 7 | + created_at timestamp with time zone not null, | |
| 8 | + constraint chk_media_size check (size_bytes > 0 and size_bytes <= 6291456) | |
| 9 | +); | |
| 10 | + | |
| 11 | +create index idx_media_asset_owner on media_asset(owner_id); |
added backend/src/test/java/com/tasteprint/ApiIntegrationTest.java +615 −0
| @@ -0,0 +1,615 @@ | ||
| 1 | +package com.tasteprint; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | +import static org.hamcrest.Matchers.greaterThan; | |
| 5 | +import static org.hamcrest.Matchers.hasSize; | |
| 6 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; | |
| 7 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |
| 8 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; | |
| 9 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; | |
| 10 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; | |
| 11 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |
| 12 | +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; | |
| 13 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; | |
| 14 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; | |
| 15 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |
| 16 | +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |
| 17 | + | |
| 18 | +import java.nio.charset.StandardCharsets; | |
| 19 | +import java.time.LocalDate; | |
| 20 | +import java.util.HashSet; | |
| 21 | +import java.util.LinkedHashMap; | |
| 22 | +import java.util.Map; | |
| 23 | +import java.util.Set; | |
| 24 | +import java.util.UUID; | |
| 25 | + | |
| 26 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 27 | +import com.fasterxml.jackson.databind.ObjectMapper; | |
| 28 | +import org.junit.jupiter.api.Test; | |
| 29 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 30 | +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; | |
| 31 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 32 | +import org.springframework.http.HttpHeaders; | |
| 33 | +import org.springframework.http.MediaType; | |
| 34 | +import org.springframework.mock.web.MockMultipartFile; | |
| 35 | +import org.springframework.test.web.servlet.MockMvc; | |
| 36 | +import org.springframework.test.web.servlet.MvcResult; | |
| 37 | + | |
| 38 | +@SpringBootTest | |
| 39 | +@AutoConfigureMockMvc | |
| 40 | +class ApiIntegrationTest { | |
| 41 | + | |
| 42 | + private static final String PASSWORD = "safe-password-42"; | |
| 43 | + | |
| 44 | + @Autowired | |
| 45 | + private MockMvc mvc; | |
| 46 | + | |
| 47 | + @Autowired | |
| 48 | + private ObjectMapper objectMapper; | |
| 49 | + | |
| 50 | + @Test | |
| 51 | + void publicCatalogAndAuthenticationBoundaryWork() throws Exception { | |
| 52 | + mvc.perform(get("/api/v1/catalog/destinations")) | |
| 53 | + .andExpect(status().isOk()) | |
| 54 | + .andExpect(jsonPath("$", hasSize(12))) | |
| 55 | + .andExpect(jsonPath("$[0].code").value("JP")) | |
| 56 | + .andExpect(jsonPath("$[0].dishCount").value(6)); | |
| 57 | + | |
| 58 | + mvc.perform(get("/api/v1/catalog/destinations/JP")) | |
| 59 | + .andExpect(status().isOk()) | |
| 60 | + .andExpect(jsonPath("$.dishes", hasSize(6))); | |
| 61 | + | |
| 62 | + mvc.perform(get("/api/v1/progress/dashboard")) | |
| 63 | + .andExpect(status().isUnauthorized()) | |
| 64 | + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) | |
| 65 | + .andExpect(jsonPath("$.detail").value("Sign in to continue.")); | |
| 66 | + | |
| 67 | + mvc.perform(get("/api/v1/progress/dashboard") | |
| 68 | + .header(HttpHeaders.AUTHORIZATION, "Bearer invalid-token")) | |
| 69 | + .andExpect(status().isUnauthorized()); | |
| 70 | + | |
| 71 | + mvc.perform(post("/api/v1/auth/register") | |
| 72 | + .contentType(MediaType.APPLICATION_JSON) | |
| 73 | + .content(json(Map.of( | |
| 74 | + "displayName", "Short Password", | |
| 75 | + "email", uniqueEmail("short"), | |
| 76 | + "password", "short" | |
| 77 | + )))) | |
| 78 | + .andExpect(status().isBadRequest()) | |
| 79 | + .andExpect(jsonPath("$.errors.password").exists()); | |
| 80 | + } | |
| 81 | + | |
| 82 | + @Test | |
| 83 | + void accountLifecycleAndPublicProfileWork() throws Exception { | |
| 84 | + String email = uniqueEmail("account"); | |
| 85 | + JsonNode session = register("Casey Traveler", email); | |
| 86 | + String token = session.get("token").asText(); | |
| 87 | + String shareSlug = session.at("/user/shareSlug").asText(); | |
| 88 | + | |
| 89 | + assertThat(token).hasSize(43); | |
| 90 | + assertThat(session.toString()).doesNotContain("password", "passwordHash"); | |
| 91 | + | |
| 92 | + mvc.perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, bearer(token))) | |
| 93 | + .andExpect(status().isOk()) | |
| 94 | + .andExpect(jsonPath("$.email").value(email)) | |
| 95 | + .andExpect(jsonPath("$.profilePublic").value(false)); | |
| 96 | + | |
| 97 | + mvc.perform(get("/api/v1/public/tasteprints/{slug}", shareSlug)) | |
| 98 | + .andExpect(status().isNotFound()); | |
| 99 | + | |
| 100 | + mvc.perform(patch("/api/v1/auth/me") | |
| 101 | + .header(HttpHeaders.AUTHORIZATION, bearer(token)) | |
| 102 | + .contentType(MediaType.APPLICATION_JSON) | |
| 103 | + .content(profile("Casey T.", true))) | |
| 104 | + .andExpect(status().isOk()) | |
| 105 | + .andExpect(jsonPath("$.displayName").value("Casey T.")) | |
| 106 | + .andExpect(jsonPath("$.homeCountryCode").value("EE")) | |
| 107 | + .andExpect(jsonPath("$.profilePublic").value(true)); | |
| 108 | + | |
| 109 | + mvc.perform(get("/api/v1/public/tasteprints/{slug}", shareSlug)) | |
| 110 | + .andExpect(status().isOk()) | |
| 111 | + .andExpect(jsonPath("$.user.displayName").value("Casey T.")) | |
| 112 | + .andExpect(jsonPath("$.user.email").doesNotExist()) | |
| 113 | + .andExpect(jsonPath("$.tasteprint.stats.totalTastings").value(0)); | |
| 114 | + | |
| 115 | + mvc.perform(patch("/api/v1/auth/me") | |
| 116 | + .header(HttpHeaders.AUTHORIZATION, bearer(token)) | |
| 117 | + .contentType(MediaType.APPLICATION_JSON) | |
| 118 | + .content(json(Map.of( | |
| 119 | + "displayName", "Casey T.", | |
| 120 | + "homeCountryCode", "EE", | |
| 121 | + "avatarUrl", "http://insecure.example/avatar.png", | |
| 122 | + "profilePublic", true | |
| 123 | + )))) | |
| 124 | + .andExpect(status().isBadRequest()) | |
| 125 | + .andExpect(jsonPath("$.errors.avatarUrl").exists()); | |
| 126 | + | |
| 127 | + mvc.perform(post("/api/v1/auth/register") | |
| 128 | + .contentType(MediaType.APPLICATION_JSON) | |
| 129 | + .content(json(Map.of("displayName", "Duplicate", "email", email, "password", PASSWORD)))) | |
| 130 | + .andExpect(status().isConflict()); | |
| 131 | + | |
| 132 | + mvc.perform(post("/api/v1/auth/login") | |
| 133 | + .contentType(MediaType.APPLICATION_JSON) | |
| 134 | + .content(json(Map.of("email", email, "password", "wrong-password")))) | |
| 135 | + .andExpect(status().isUnauthorized()) | |
| 136 | + .andExpect(jsonPath("$.detail").value("Invalid email or password.")); | |
| 137 | + | |
| 138 | + mvc.perform(post("/api/v1/auth/logout").header(HttpHeaders.AUTHORIZATION, bearer(token))) | |
| 139 | + .andExpect(status().isNoContent()); | |
| 140 | + | |
| 141 | + mvc.perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, bearer(token))) | |
| 142 | + .andExpect(status().isUnauthorized()); | |
| 143 | + } | |
| 144 | + | |
| 145 | + @Test | |
| 146 | + void tastingCrudValidationPagingAndOwnershipWork() throws Exception { | |
| 147 | + String ownerToken = register("Tasting Owner", uniqueEmail("taste-owner")).get("token").asText(); | |
| 148 | + String otherToken = register("Other Taster", uniqueEmail("taste-other")).get("token").asText(); | |
| 149 | + LocalDate today = LocalDate.now(); | |
| 150 | + | |
| 151 | + MvcResult created = mvc.perform(post("/api/v1/tastings") | |
| 152 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)) | |
| 153 | + .contentType(MediaType.APPLICATION_JSON) | |
| 154 | + .content(tasting("ramen", "Tokyo", "jp", today, 5))) | |
| 155 | + .andExpect(status().isCreated()) | |
| 156 | + .andExpect(jsonPath("$.dish.slug").value("ramen")) | |
| 157 | + .andExpect(jsonPath("$.countryCode").value("JP")) | |
| 158 | + .andReturn(); | |
| 159 | + String tastingId = body(created).get("id").asText(); | |
| 160 | + | |
| 161 | + mvc.perform(get("/api/v1/tastings?page=-4&size=500") | |
| 162 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))) | |
| 163 | + .andExpect(status().isOk()) | |
| 164 | + .andExpect(jsonPath("$.page").value(0)) | |
| 165 | + .andExpect(jsonPath("$.size").value(50)) | |
| 166 | + .andExpect(jsonPath("$.totalItems").value(1)); | |
| 167 | + | |
| 168 | + mvc.perform(get("/api/v1/tastings").header(HttpHeaders.AUTHORIZATION, bearer(otherToken))) | |
| 169 | + .andExpect(status().isOk()) | |
| 170 | + .andExpect(jsonPath("$.totalItems").value(0)); | |
| 171 | + | |
| 172 | + mvc.perform(put("/api/v1/tastings/{id}", tastingId) | |
| 173 | + .header(HttpHeaders.AUTHORIZATION, bearer(otherToken)) | |
| 174 | + .contentType(MediaType.APPLICATION_JSON) | |
| 175 | + .content(tasting("sushi", "Kyoto", "JP", today, 4))) | |
| 176 | + .andExpect(status().isNotFound()); | |
| 177 | + | |
| 178 | + mvc.perform(put("/api/v1/tastings/{id}", tastingId) | |
| 179 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)) | |
| 180 | + .contentType(MediaType.APPLICATION_JSON) | |
| 181 | + .content(tasting("sushi", "Kyoto", "JP", today, 4))) | |
| 182 | + .andExpect(status().isOk()) | |
| 183 | + .andExpect(jsonPath("$.dish.slug").value("sushi")) | |
| 184 | + .andExpect(jsonPath("$.rating").value(4)); | |
| 185 | + | |
| 186 | + mvc.perform(post("/api/v1/tastings") | |
| 187 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)) | |
| 188 | + .contentType(MediaType.APPLICATION_JSON) | |
| 189 | + .content(tasting("ramen", "Tokyo", "JP", today.plusDays(1), 5))) | |
| 190 | + .andExpect(status().isBadRequest()) | |
| 191 | + .andExpect(jsonPath("$.errors.tastedOn").exists()); | |
| 192 | + | |
| 193 | + Map<String, Object> invalidCoordinates = tastingMap("ramen", "Tokyo", "JP", today, 5); | |
| 194 | + invalidCoordinates.put("latitude", 59.4); | |
| 195 | + mvc.perform(post("/api/v1/tastings") | |
| 196 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)) | |
| 197 | + .contentType(MediaType.APPLICATION_JSON) | |
| 198 | + .content(json(invalidCoordinates))) | |
| 199 | + .andExpect(status().isBadRequest()); | |
| 200 | + | |
| 201 | + mvc.perform(delete("/api/v1/tastings/{id}", tastingId) | |
| 202 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))) | |
| 203 | + .andExpect(status().isNoContent()); | |
| 204 | + | |
| 205 | + mvc.perform(get("/api/v1/tastings").header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))) | |
| 206 | + .andExpect(status().isOk()) | |
| 207 | + .andExpect(jsonPath("$.totalItems").value(0)); | |
| 208 | + } | |
| 209 | + | |
| 210 | + @Test | |
| 211 | + void tripMissionUsesOnlyMatchingTastingsAndSurvivesTripDeletion() throws Exception { | |
| 212 | + String ownerToken = register("Trip Owner", uniqueEmail("trip-owner")).get("token").asText(); | |
| 213 | + String otherToken = register("Trip Stranger", uniqueEmail("trip-other")).get("token").asText(); | |
| 214 | + LocalDate startsOn = LocalDate.now().minusDays(4); | |
| 215 | + LocalDate endsOn = LocalDate.now(); | |
| 216 | + | |
| 217 | + MvcResult created = mvc.perform(post("/api/v1/trips") | |
| 218 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)) | |
| 219 | + .contentType(MediaType.APPLICATION_JSON) | |
| 220 | + .content(trip("JP", "Tokyo and Osaka", startsOn, endsOn))) | |
| 221 | + .andExpect(status().isCreated()) | |
| 222 | + .andExpect(jsonPath("$.status").value("ACTIVE")) | |
| 223 | + .andExpect(jsonPath("$.mission", hasSize(5))) | |
| 224 | + .andExpect(jsonPath("$.missionCompleted").value(0)) | |
| 225 | + .andReturn(); | |
| 226 | + JsonNode trip = body(created); | |
| 227 | + String tripId = trip.get("id").asText(); | |
| 228 | + String missionDish = trip.at("/mission/0/dish/slug").asText(); | |
| 229 | + Set<String> missionDishes = new HashSet<>(); | |
| 230 | + trip.get("mission").forEach(item -> missionDishes.add(item.at("/dish/slug").asText())); | |
| 231 | + assertThat(missionDishes).hasSize(5); | |
| 232 | + | |
| 233 | + mvc.perform(get("/api/v1/trips/{id}", tripId) | |
| 234 | + .header(HttpHeaders.AUTHORIZATION, bearer(otherToken))) | |
| 235 | + .andExpect(status().isNotFound()); | |
| 236 | + | |
| 237 | + mvc.perform(put("/api/v1/trips/{id}", tripId) | |
| 238 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)) | |
| 239 | + .contentType(MediaType.APPLICATION_JSON) | |
| 240 | + .content(trip("MX", "Changed", startsOn, endsOn))) | |
| 241 | + .andExpect(status().isBadRequest()); | |
| 242 | + | |
| 243 | + mvc.perform(post("/api/v1/tastings") | |
| 244 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)) | |
| 245 | + .contentType(MediaType.APPLICATION_JSON) | |
| 246 | + .content(tasting(missionDish, "Tokyo", "EE", startsOn, 5))) | |
| 247 | + .andExpect(status().isCreated()); | |
| 248 | + | |
| 249 | + mvc.perform(get("/api/v1/trips/{id}", tripId) | |
| 250 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))) | |
| 251 | + .andExpect(status().isOk()) | |
| 252 | + .andExpect(jsonPath("$.missionCompleted").value(0)); | |
| 253 | + | |
| 254 | + mvc.perform(post("/api/v1/tastings") | |
| 255 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)) | |
| 256 | + .contentType(MediaType.APPLICATION_JSON) | |
| 257 | + .content(tasting(missionDish, "Tokyo", "JP", startsOn, 5))) | |
| 258 | + .andExpect(status().isCreated()); | |
| 259 | + | |
| 260 | + mvc.perform(get("/api/v1/trips/{id}", tripId) | |
| 261 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))) | |
| 262 | + .andExpect(status().isOk()) | |
| 263 | + .andExpect(jsonPath("$.missionCompleted").value(1)) | |
| 264 | + .andExpect(jsonPath("$.culinaryCoverage", greaterThan(0))); | |
| 265 | + | |
| 266 | + mvc.perform(delete("/api/v1/trips/{id}", tripId) | |
| 267 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))) | |
| 268 | + .andExpect(status().isNoContent()); | |
| 269 | + | |
| 270 | + mvc.perform(get("/api/v1/tastings").header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))) | |
| 271 | + .andExpect(status().isOk()) | |
| 272 | + .andExpect(jsonPath("$.totalItems").value(2)); | |
| 273 | + } | |
| 274 | + | |
| 275 | + @Test | |
| 276 | + void collaborativeChallengeEnforcesMembershipAndCombinesProgress() throws Exception { | |
| 277 | + JsonNode owner = register("Challenge Owner", uniqueEmail("challenge-owner")); | |
| 278 | + JsonNode member = register("Challenge Member", uniqueEmail("challenge-member")); | |
| 279 | + String ownerToken = owner.get("token").asText(); | |
| 280 | + String memberToken = member.get("token").asText(); | |
| 281 | + LocalDate startsOn = LocalDate.now().minusDays(2); | |
| 282 | + LocalDate endsOn = LocalDate.now().plusDays(2); | |
| 283 | + | |
| 284 | + MvcResult created = mvc.perform(post("/api/v1/challenges") | |
| 285 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)) | |
| 286 | + .contentType(MediaType.APPLICATION_JSON) | |
| 287 | + .content(challenge("Japan table", "JP", startsOn, endsOn))) | |
| 288 | + .andExpect(status().isCreated()) | |
| 289 | + .andExpect(jsonPath("$.participants", hasSize(1))) | |
| 290 | + .andReturn(); | |
| 291 | + JsonNode challenge = body(created); | |
| 292 | + String challengeId = challenge.get("id").asText(); | |
| 293 | + String joinCode = challenge.get("joinCode").asText(); | |
| 294 | + assertThat(joinCode).hasSize(6).matches("[A-Z2-9]+$"); | |
| 295 | + | |
| 296 | + mvc.perform(get("/api/v1/challenges/{id}", challengeId) | |
| 297 | + .header(HttpHeaders.AUTHORIZATION, bearer(memberToken))) | |
| 298 | + .andExpect(status().isForbidden()); | |
| 299 | + | |
| 300 | + mvc.perform(post("/api/v1/challenges/join") | |
| 301 | + .header(HttpHeaders.AUTHORIZATION, bearer(memberToken)) | |
| 302 | + .contentType(MediaType.APPLICATION_JSON) | |
| 303 | + .content(json(Map.of("joinCode", joinCode.toLowerCase())))) | |
| 304 | + .andExpect(status().isOk()) | |
| 305 | + .andExpect(jsonPath("$.participants", hasSize(2))); | |
| 306 | + | |
| 307 | + mvc.perform(delete("/api/v1/challenges/{id}/members/me", challengeId) | |
| 308 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))) | |
| 309 | + .andExpect(status().isBadRequest()); | |
| 310 | + | |
| 311 | + mvc.perform(post("/api/v1/tastings") | |
| 312 | + .header(HttpHeaders.AUTHORIZATION, bearer(memberToken)) | |
| 313 | + .contentType(MediaType.APPLICATION_JSON) | |
| 314 | + .content(tasting("ramen", "Tallinn", "EE", LocalDate.now(), 4))) | |
| 315 | + .andExpect(status().isCreated()); | |
| 316 | + | |
| 317 | + mvc.perform(get("/api/v1/challenges/{id}", challengeId) | |
| 318 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))) | |
| 319 | + .andExpect(status().isOk()) | |
| 320 | + .andExpect(jsonPath("$.groupCoverage", greaterThan(0))) | |
| 321 | + .andExpect(jsonPath("$.participants[0].contributedDishes").value(1)); | |
| 322 | + | |
| 323 | + mvc.perform(delete("/api/v1/challenges/{id}", challengeId) | |
| 324 | + .header(HttpHeaders.AUTHORIZATION, bearer(memberToken))) | |
| 325 | + .andExpect(status().isForbidden()); | |
| 326 | + | |
| 327 | + mvc.perform(delete("/api/v1/challenges/{id}/members/me", challengeId) | |
| 328 | + .header(HttpHeaders.AUTHORIZATION, bearer(memberToken))) | |
| 329 | + .andExpect(status().isNoContent()); | |
| 330 | + | |
| 331 | + mvc.perform(get("/api/v1/challenges/{id}", challengeId) | |
| 332 | + .header(HttpHeaders.AUTHORIZATION, bearer(memberToken))) | |
| 333 | + .andExpect(status().isForbidden()); | |
| 334 | + | |
| 335 | + mvc.perform(delete("/api/v1/challenges/{id}", challengeId) | |
| 336 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))) | |
| 337 | + .andExpect(status().isNoContent()); | |
| 338 | + } | |
| 339 | + | |
| 340 | + @Test | |
| 341 | + void publicComparisonRespectsPrivacyAndSuggestsANewDish() throws Exception { | |
| 342 | + JsonNode first = register("First Map", uniqueEmail("compare-first")); | |
| 343 | + JsonNode second = register("Second Map", uniqueEmail("compare-second")); | |
| 344 | + String firstToken = first.get("token").asText(); | |
| 345 | + String secondToken = second.get("token").asText(); | |
| 346 | + String secondSlug = second.at("/user/shareSlug").asText(); | |
| 347 | + | |
| 348 | + mvc.perform(get("/api/v1/social/compare/{slug}", secondSlug) | |
| 349 | + .header(HttpHeaders.AUTHORIZATION, bearer(firstToken))) | |
| 350 | + .andExpect(status().isNotFound()); | |
| 351 | + | |
| 352 | + mvc.perform(patch("/api/v1/auth/me") | |
| 353 | + .header(HttpHeaders.AUTHORIZATION, bearer(secondToken)) | |
| 354 | + .contentType(MediaType.APPLICATION_JSON) | |
| 355 | + .content(profile("Second Map", true))) | |
| 356 | + .andExpect(status().isOk()); | |
| 357 | + | |
| 358 | + mvc.perform(post("/api/v1/tastings") | |
| 359 | + .header(HttpHeaders.AUTHORIZATION, bearer(firstToken)) | |
| 360 | + .contentType(MediaType.APPLICATION_JSON) | |
| 361 | + .content(tasting("ramen", "Tokyo", "JP", LocalDate.now(), 5))) | |
| 362 | + .andExpect(status().isCreated()); | |
| 363 | + mvc.perform(post("/api/v1/tastings") | |
| 364 | + .header(HttpHeaders.AUTHORIZATION, bearer(secondToken)) | |
| 365 | + .contentType(MediaType.APPLICATION_JSON) | |
| 366 | + .content(tasting("ramen", "Tokyo", "JP", LocalDate.now(), 4))) | |
| 367 | + .andExpect(status().isCreated()); | |
| 368 | + Map<String, Object> publicTasting = tastingMap("sushi", "Tokyo", "JP", LocalDate.now(), 5); | |
| 369 | + publicTasting.put("restaurantName", "Private table"); | |
| 370 | + publicTasting.put("note", "A public note without a precise location."); | |
| 371 | + publicTasting.put("latitude", 35.6762); | |
| 372 | + publicTasting.put("longitude", 139.6503); | |
| 373 | + mvc.perform(post("/api/v1/tastings") | |
| 374 | + .header(HttpHeaders.AUTHORIZATION, bearer(secondToken)) | |
| 375 | + .contentType(MediaType.APPLICATION_JSON) | |
| 376 | + .content(json(publicTasting))) | |
| 377 | + .andExpect(status().isCreated()); | |
| 378 | + | |
| 379 | + mvc.perform(get("/api/v1/public/tasteprints/{slug}", secondSlug)) | |
| 380 | + .andExpect(status().isOk()) | |
| 381 | + .andExpect(jsonPath("$.tasteprint.recentTastings[0].city").value("Tokyo")) | |
| 382 | + .andExpect(jsonPath("$.tasteprint.recentTastings[0].note").exists()) | |
| 383 | + .andExpect(jsonPath("$.tasteprint.recentTastings[0].restaurantName").doesNotExist()) | |
| 384 | + .andExpect(jsonPath("$.tasteprint.recentTastings[0].latitude").doesNotExist()) | |
| 385 | + .andExpect(jsonPath("$.tasteprint.recentTastings[0].longitude").doesNotExist()) | |
| 386 | + .andExpect(jsonPath("$.tasteprint.recentTastings[0].createdAt").doesNotExist()); | |
| 387 | + | |
| 388 | + mvc.perform(get("/api/v1/social/compare/{slug}", secondSlug) | |
| 389 | + .header(HttpHeaders.AUTHORIZATION, bearer(firstToken))) | |
| 390 | + .andExpect(status().isOk()) | |
| 391 | + .andExpect(jsonPath("$.overlapScore").value(50)) | |
| 392 | + .andExpect(jsonPath("$.sharedDishes").value(1)) | |
| 393 | + .andExpect(jsonPath("$.sharedCountries", hasSize(1))) | |
| 394 | + .andExpect(jsonPath("$.suggestedSharedBite.slug").value("sushi")); | |
| 395 | + } | |
| 396 | + | |
| 397 | + @Test | |
| 398 | + void mediaUploadChecksAuthenticationTypeSizeAndSignature() throws Exception { | |
| 399 | + String token = register("Photo Owner", uniqueEmail("photo")).get("token").asText(); | |
| 400 | + String otherToken = register("Photo Stranger", uniqueEmail("photo-stranger")).get("token").asText(); | |
| 401 | + byte[] pngSignature = new byte[] {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; | |
| 402 | + MockMultipartFile valid = new MockMultipartFile("file", "meal.png", "image/png", pngSignature); | |
| 403 | + | |
| 404 | + mvc.perform(multipart("/api/v1/media").file(valid)) | |
| 405 | + .andExpect(status().isUnauthorized()); | |
| 406 | + | |
| 407 | + MvcResult uploaded = mvc.perform(multipart("/api/v1/media").file(valid) | |
| 408 | + .header(HttpHeaders.AUTHORIZATION, bearer(token))) | |
| 409 | + .andExpect(status().isCreated()) | |
| 410 | + .andExpect(jsonPath("$.url").value(org.hamcrest.Matchers.matchesPattern("/uploads/[a-f0-9-]+\\.png"))) | |
| 411 | + .andExpect(jsonPath("$.contentType").value("image/png")) | |
| 412 | + .andExpect(jsonPath("$.size").value(8)) | |
| 413 | + .andReturn(); | |
| 414 | + String photoUrl = body(uploaded).get("url").asText(); | |
| 415 | + | |
| 416 | + mvc.perform(get(photoUrl)) | |
| 417 | + .andExpect(status().isOk()) | |
| 418 | + .andExpect(content().bytes(pngSignature)); | |
| 419 | + | |
| 420 | + Map<String, Object> stolenPhoto = tastingMap("ramen", "Tokyo", "JP", LocalDate.now(), 5); | |
| 421 | + stolenPhoto.put("photoUrl", photoUrl); | |
| 422 | + mvc.perform(post("/api/v1/tastings") | |
| 423 | + .header(HttpHeaders.AUTHORIZATION, bearer(otherToken)) | |
| 424 | + .contentType(MediaType.APPLICATION_JSON) | |
| 425 | + .content(json(stolenPhoto))) | |
| 426 | + .andExpect(status().isForbidden()); | |
| 427 | + | |
| 428 | + MvcResult firstTasting = mvc.perform(post("/api/v1/tastings") | |
| 429 | + .header(HttpHeaders.AUTHORIZATION, bearer(token)) | |
| 430 | + .contentType(MediaType.APPLICATION_JSON) | |
| 431 | + .content(json(stolenPhoto))) | |
| 432 | + .andExpect(status().isCreated()) | |
| 433 | + .andReturn(); | |
| 434 | + Map<String, Object> reusedPhoto = tastingMap("sushi", "Kyoto", "JP", LocalDate.now(), 4); | |
| 435 | + reusedPhoto.put("photoUrl", photoUrl); | |
| 436 | + MvcResult secondTasting = mvc.perform(post("/api/v1/tastings") | |
| 437 | + .header(HttpHeaders.AUTHORIZATION, bearer(token)) | |
| 438 | + .contentType(MediaType.APPLICATION_JSON) | |
| 439 | + .content(json(reusedPhoto))) | |
| 440 | + .andExpect(status().isCreated()) | |
| 441 | + .andReturn(); | |
| 442 | + | |
| 443 | + mvc.perform(delete("/api/v1/tastings/{id}", body(firstTasting).get("id").asText()) | |
| 444 | + .header(HttpHeaders.AUTHORIZATION, bearer(token))) | |
| 445 | + .andExpect(status().isNoContent()); | |
| 446 | + mvc.perform(get(photoUrl)).andExpect(status().isOk()); | |
| 447 | + | |
| 448 | + mvc.perform(delete("/api/v1/tastings/{id}", body(secondTasting).get("id").asText()) | |
| 449 | + .header(HttpHeaders.AUTHORIZATION, bearer(token))) | |
| 450 | + .andExpect(status().isNoContent()); | |
| 451 | + mvc.perform(get(photoUrl)).andExpect(status().isNotFound()); | |
| 452 | + | |
| 453 | + MockMultipartFile fakePng = new MockMultipartFile( | |
| 454 | + "file", "fake.png", "image/png", "not an image".getBytes(StandardCharsets.UTF_8) | |
| 455 | + ); | |
| 456 | + mvc.perform(multipart("/api/v1/media").file(fakePng) | |
| 457 | + .header(HttpHeaders.AUTHORIZATION, bearer(token))) | |
| 458 | + .andExpect(status().isBadRequest()) | |
| 459 | + .andExpect(jsonPath("$.detail").value("The uploaded file is not a valid image.")); | |
| 460 | + | |
| 461 | + MockMultipartFile tooLarge = new MockMultipartFile( | |
| 462 | + "file", "large.png", "image/png", new byte[6 * 1024 * 1024 + 1] | |
| 463 | + ); | |
| 464 | + mvc.perform(multipart("/api/v1/media").file(tooLarge) | |
| 465 | + .header(HttpHeaders.AUTHORIZATION, bearer(token))) | |
| 466 | + .andExpect(status().isBadRequest()) | |
| 467 | + .andExpect(jsonPath("$.detail").value("Photo must be smaller than 6 MB.")); | |
| 468 | + } | |
| 469 | + | |
| 470 | + @Test | |
| 471 | + void accountDeletionRequiresPasswordAndRemovesOwnedChallenges() throws Exception { | |
| 472 | + String ownerEmail = uniqueEmail("delete-owner"); | |
| 473 | + String ownerToken = register("Delete Owner", ownerEmail).get("token").asText(); | |
| 474 | + String memberToken = register("Remaining Member", uniqueEmail("delete-member")).get("token").asText(); | |
| 475 | + LocalDate today = LocalDate.now(); | |
| 476 | + byte[] pngSignature = new byte[] {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; | |
| 477 | + MvcResult orphanUpload = mvc.perform(multipart("/api/v1/media") | |
| 478 | + .file(new MockMultipartFile("file", "orphan.png", "image/png", pngSignature)) | |
| 479 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))) | |
| 480 | + .andExpect(status().isCreated()) | |
| 481 | + .andReturn(); | |
| 482 | + String orphanUrl = body(orphanUpload).get("url").asText(); | |
| 483 | + | |
| 484 | + MvcResult created = mvc.perform(post("/api/v1/challenges") | |
| 485 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)) | |
| 486 | + .contentType(MediaType.APPLICATION_JSON) | |
| 487 | + .content(challenge("Temporary table", "PT", today, today.plusDays(5)))) | |
| 488 | + .andExpect(status().isCreated()) | |
| 489 | + .andReturn(); | |
| 490 | + String joinCode = body(created).get("joinCode").asText(); | |
| 491 | + | |
| 492 | + mvc.perform(post("/api/v1/challenges/join") | |
| 493 | + .header(HttpHeaders.AUTHORIZATION, bearer(memberToken)) | |
| 494 | + .contentType(MediaType.APPLICATION_JSON) | |
| 495 | + .content(json(Map.of("joinCode", joinCode)))) | |
| 496 | + .andExpect(status().isOk()); | |
| 497 | + | |
| 498 | + mvc.perform(delete("/api/v1/auth/me") | |
| 499 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)) | |
| 500 | + .contentType(MediaType.APPLICATION_JSON) | |
| 501 | + .content(json(Map.of("password", "wrong-password")))) | |
| 502 | + .andExpect(status().isUnauthorized()) | |
| 503 | + .andExpect(jsonPath("$.detail").value("Invalid password.")); | |
| 504 | + | |
| 505 | + mvc.perform(delete("/api/v1/auth/me") | |
| 506 | + .header(HttpHeaders.AUTHORIZATION, bearer(ownerToken)) | |
| 507 | + .contentType(MediaType.APPLICATION_JSON) | |
| 508 | + .content(json(Map.of("password", PASSWORD)))) | |
| 509 | + .andExpect(status().isNoContent()); | |
| 510 | + | |
| 511 | + mvc.perform(get("/api/v1/auth/me").header(HttpHeaders.AUTHORIZATION, bearer(ownerToken))) | |
| 512 | + .andExpect(status().isUnauthorized()); | |
| 513 | + mvc.perform(get(orphanUrl)).andExpect(status().isNotFound()); | |
| 514 | + | |
| 515 | + mvc.perform(get("/api/v1/challenges").header(HttpHeaders.AUTHORIZATION, bearer(memberToken))) | |
| 516 | + .andExpect(status().isOk()) | |
| 517 | + .andExpect(jsonPath("$", hasSize(0))); | |
| 518 | + | |
| 519 | + mvc.perform(post("/api/v1/auth/login") | |
| 520 | + .contentType(MediaType.APPLICATION_JSON) | |
| 521 | + .content(json(Map.of("email", ownerEmail, "password", PASSWORD)))) | |
| 522 | + .andExpect(status().isUnauthorized()); | |
| 523 | + } | |
| 524 | + | |
| 525 | + @Test | |
| 526 | + void corsAllowsConfiguredFrontendAndRejectsUnknownOrigins() throws Exception { | |
| 527 | + mvc.perform(options("/api/v1/catalog/destinations") | |
| 528 | + .header(HttpHeaders.ORIGIN, "http://localhost:5173") | |
| 529 | + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")) | |
| 530 | + .andExpect(status().isOk()) | |
| 531 | + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "http://localhost:5173")); | |
| 532 | + | |
| 533 | + mvc.perform(options("/api/v1/catalog/destinations") | |
| 534 | + .header(HttpHeaders.ORIGIN, "https://unknown.example") | |
| 535 | + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")) | |
| 536 | + .andExpect(status().isForbidden()); | |
| 537 | + } | |
| 538 | + | |
| 539 | + private JsonNode register(String displayName, String email) throws Exception { | |
| 540 | + MvcResult result = mvc.perform(post("/api/v1/auth/register") | |
| 541 | + .contentType(MediaType.APPLICATION_JSON) | |
| 542 | + .content(json(Map.of( | |
| 543 | + "displayName", displayName, | |
| 544 | + "email", email, | |
| 545 | + "password", PASSWORD | |
| 546 | + )))) | |
| 547 | + .andExpect(status().isCreated()) | |
| 548 | + .andExpect(jsonPath("$.token").isString()) | |
| 549 | + .andReturn(); | |
| 550 | + return body(result); | |
| 551 | + } | |
| 552 | + | |
| 553 | + private String uniqueEmail(String prefix) { | |
| 554 | + return prefix + "+" + UUID.randomUUID() + "@example.com"; | |
| 555 | + } | |
| 556 | + | |
| 557 | + private String bearer(String token) { | |
| 558 | + return "Bearer " + token; | |
| 559 | + } | |
| 560 | + | |
| 561 | + private String tasting(String dishSlug, String city, String countryCode, LocalDate date, int rating) | |
| 562 | + throws Exception { | |
| 563 | + return json(tastingMap(dishSlug, city, countryCode, date, rating)); | |
| 564 | + } | |
| 565 | + | |
| 566 | + private Map<String, Object> tastingMap(String dishSlug, String city, String countryCode, | |
| 567 | + LocalDate date, int rating) { | |
| 568 | + Map<String, Object> value = new LinkedHashMap<>(); | |
| 569 | + value.put("dishSlug", dishSlug); | |
| 570 | + value.put("city", city); | |
| 571 | + value.put("countryCode", countryCode); | |
| 572 | + value.put("tastedOn", date.toString()); | |
| 573 | + value.put("rating", rating); | |
| 574 | + return value; | |
| 575 | + } | |
| 576 | + | |
| 577 | + private String trip(String destinationCode, String city, LocalDate startsOn, LocalDate endsOn) | |
| 578 | + throws Exception { | |
| 579 | + return json(Map.of( | |
| 580 | + "destinationCode", destinationCode, | |
| 581 | + "city", city, | |
| 582 | + "startsOn", startsOn.toString(), | |
| 583 | + "endsOn", endsOn.toString() | |
| 584 | + )); | |
| 585 | + } | |
| 586 | + | |
| 587 | + private String challenge(String title, String destinationCode, LocalDate startsOn, LocalDate endsOn) | |
| 588 | + throws Exception { | |
| 589 | + return json(Map.of( | |
| 590 | + "title", title, | |
| 591 | + "destinationCode", destinationCode, | |
| 592 | + "startsOn", startsOn.toString(), | |
| 593 | + "endsOn", endsOn.toString() | |
| 594 | + )); | |
| 595 | + } | |
| 596 | + | |
| 597 | + private String profile(String displayName, boolean profilePublic) throws Exception { | |
| 598 | + Map<String, Object> profile = new LinkedHashMap<>(); | |
| 599 | + profile.put("displayName", displayName); | |
| 600 | + profile.put("homeCity", "Tallinn"); | |
| 601 | + profile.put("homeCountryCode", "ee"); | |
| 602 | + profile.put("bio", "Trips remembered through food."); | |
| 603 | + profile.put("avatarUrl", null); | |
| 604 | + profile.put("profilePublic", profilePublic); | |
| 605 | + return json(profile); | |
| 606 | + } | |
| 607 | + | |
| 608 | + private String json(Object value) throws Exception { | |
| 609 | + return objectMapper.writeValueAsString(value); | |
| 610 | + } | |
| 611 | + | |
| 612 | + private JsonNode body(MvcResult result) throws Exception { | |
| 613 | + return objectMapper.readTree(result.getResponse().getContentAsByteArray()); | |
| 614 | + } | |
| 615 | +} |
added backend/src/test/java/com/tasteprint/ApplicationContextTest.java +12 −0
| @@ -0,0 +1,12 @@ | ||
| 1 | +package com.tasteprint; | |
| 2 | + | |
| 3 | +import org.junit.jupiter.api.Test; | |
| 4 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 5 | + | |
| 6 | +@SpringBootTest | |
| 7 | +class ApplicationContextTest { | |
| 8 | + | |
| 9 | + @Test | |
| 10 | + void applicationStarts() { | |
| 11 | + } | |
| 12 | +} |
added backend/src/test/java/com/tasteprint/ModularityTest.java +12 −0
| @@ -0,0 +1,12 @@ | ||
| 1 | +package com.tasteprint; | |
| 2 | + | |
| 3 | +import org.junit.jupiter.api.Test; | |
| 4 | +import org.springframework.modulith.core.ApplicationModules; | |
| 5 | + | |
| 6 | +class ModularityTest { | |
| 7 | + | |
| 8 | + @Test | |
| 9 | + void moduleBoundariesAreValid() { | |
| 10 | + ApplicationModules.of(TasteprintApplication.class).verify(); | |
| 11 | + } | |
| 12 | +} |
added backend/src/test/java/com/tasteprint/PostgresCompatibilityTest.java +65 −0
| @@ -0,0 +1,65 @@ | ||
| 1 | +package com.tasteprint; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import java.time.LocalDate; | |
| 6 | +import java.util.UUID; | |
| 7 | + | |
| 8 | +import com.tasteprint.account.AccountService; | |
| 9 | +import com.tasteprint.account.AuthSession; | |
| 10 | +import com.tasteprint.account.RegisterRequest; | |
| 11 | +import com.tasteprint.progress.ProgressService; | |
| 12 | +import com.tasteprint.tasting.SaveTastingRequest; | |
| 13 | +import com.tasteprint.tasting.TastingService; | |
| 14 | +import org.junit.jupiter.api.Test; | |
| 15 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 16 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 17 | +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; | |
| 18 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 19 | +import org.testcontainers.containers.PostgreSQLContainer; | |
| 20 | +import org.testcontainers.junit.jupiter.Container; | |
| 21 | +import org.testcontainers.junit.jupiter.Testcontainers; | |
| 22 | + | |
| 23 | +@SpringBootTest | |
| 24 | +@Testcontainers(disabledWithoutDocker = true) | |
| 25 | +class PostgresCompatibilityTest { | |
| 26 | + | |
| 27 | + @Container | |
| 28 | + @ServiceConnection | |
| 29 | + static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine"); | |
| 30 | + | |
| 31 | + @Autowired | |
| 32 | + private JdbcTemplate jdbc; | |
| 33 | + | |
| 34 | + @Autowired | |
| 35 | + private AccountService accounts; | |
| 36 | + | |
| 37 | + @Autowired | |
| 38 | + private TastingService tastings; | |
| 39 | + | |
| 40 | + @Autowired | |
| 41 | + private ProgressService progress; | |
| 42 | + | |
| 43 | + @Test | |
| 44 | + void migrationsAndCoreWriteFlowWorkOnPostgres() throws Exception { | |
| 45 | + assertThat(jdbc.getDataSource()).isNotNull(); | |
| 46 | + try (var connection = jdbc.getDataSource().getConnection()) { | |
| 47 | + assertThat(connection.getMetaData().getDatabaseProductName()).isEqualTo("PostgreSQL"); | |
| 48 | + } | |
| 49 | + assertThat(jdbc.queryForObject("select count(*) from destination", Integer.class)).isEqualTo(12); | |
| 50 | + assertThat(jdbc.queryForObject("select count(*) from dish", Integer.class)).isEqualTo(72); | |
| 51 | + | |
| 52 | + AuthSession session = accounts.register(new RegisterRequest( | |
| 53 | + "Postgres Traveler", | |
| 54 | + "postgres+" + UUID.randomUUID() + "@example.com", | |
| 55 | + "safe-password-42" | |
| 56 | + )); | |
| 57 | + tastings.record(session.user().id(), new SaveTastingRequest( | |
| 58 | + "ramen", "Test counter", "Tokyo", "JP", LocalDate.now(), 5, | |
| 59 | + "Verified on PostgreSQL.", null, null, null | |
| 60 | + )); | |
| 61 | + | |
| 62 | + assertThat(progress.snapshot(session.user().id()).stats().totalTastings()).isEqualTo(1); | |
| 63 | + assertThat(progress.snapshot(session.user().id()).stats().countriesTasted()).isEqualTo(1); | |
| 64 | + } | |
| 65 | +} |
added backend/src/test/resources/application.yml +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +spring: | |
| 2 | + datasource: | |
| 3 | + url: jdbc:h2:mem:tasteprint;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH | |
| 4 | + username: sa | |
| 5 | + password: | |
| 6 | + jpa: | |
| 7 | + hibernate: | |
| 8 | + ddl-auto: validate | |
| 9 | + open-in-view: false | |
| 10 | + | |
| 11 | +app: | |
| 12 | + allowed-origins: http://localhost:5173 | |
| 13 | + media-directory: ./target/test-uploads | |
| 14 | + demo-data-enabled: false |
added compose.yml +59 −0
| @@ -0,0 +1,59 @@ | ||
| 1 | +services: | |
| 2 | + db: | |
| 3 | + image: postgres:17-alpine | |
| 4 | + environment: | |
| 5 | + POSTGRES_DB: ${POSTGRES_DB:-tasteprint} | |
| 6 | + POSTGRES_USER: ${POSTGRES_USER:-tasteprint} | |
| 7 | + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-tasteprint-local} | |
| 8 | + volumes: | |
| 9 | + - postgres_data:/var/lib/postgresql/data | |
| 10 | + healthcheck: | |
| 11 | + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] | |
| 12 | + interval: 5s | |
| 13 | + timeout: 3s | |
| 14 | + retries: 10 | |
| 15 | + restart: unless-stopped | |
| 16 | + | |
| 17 | + backend: | |
| 18 | + build: ./backend | |
| 19 | + environment: | |
| 20 | + DB_URL: jdbc:postgresql://db:5432/${POSTGRES_DB:-tasteprint} | |
| 21 | + DB_USER: ${POSTGRES_USER:-tasteprint} | |
| 22 | + DB_PASSWORD: ${POSTGRES_PASSWORD:-tasteprint-local} | |
| 23 | + APP_ALLOWED_ORIGINS: ${APP_ALLOWED_ORIGINS:-http://localhost:3000} | |
| 24 | + DEMO_DATA_ENABLED: ${DEMO_DATA_ENABLED:-true} | |
| 25 | + MEDIA_DIRECTORY: /app/uploads | |
| 26 | + SERVER_SHUTDOWN: graceful | |
| 27 | + SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: 20s | |
| 28 | + volumes: | |
| 29 | + - uploaded_media:/app/uploads | |
| 30 | + ports: | |
| 31 | + - "127.0.0.1:8080:8080" | |
| 32 | + depends_on: | |
| 33 | + db: | |
| 34 | + condition: service_healthy | |
| 35 | + healthcheck: | |
| 36 | + test: ["CMD", "wget", "-qO-", "http://localhost:8080/actuator/health/readiness"] | |
| 37 | + interval: 10s | |
| 38 | + timeout: 4s | |
| 39 | + retries: 12 | |
| 40 | + start_period: 20s | |
| 41 | + restart: unless-stopped | |
| 42 | + | |
| 43 | + frontend: | |
| 44 | + build: ./frontend | |
| 45 | + ports: | |
| 46 | + - "3000:80" | |
| 47 | + depends_on: | |
| 48 | + backend: | |
| 49 | + condition: service_healthy | |
| 50 | + healthcheck: | |
| 51 | + test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"] | |
| 52 | + interval: 10s | |
| 53 | + timeout: 3s | |
| 54 | + retries: 5 | |
| 55 | + restart: unless-stopped | |
| 56 | + | |
| 57 | +volumes: | |
| 58 | + postgres_data: | |
| 59 | + uploaded_media: |
added docs/ARCHITECTURE.md +113 −0
| @@ -0,0 +1,113 @@ | ||
| 1 | +# Tasteprint architecture | |
| 2 | + | |
| 3 | +## Decision | |
| 4 | + | |
| 5 | +Tasteprint starts as a modular monolith because its current domains share consistency rules and its expected traffic does not justify independent infrastructure. A tasting updates the journal, trip mission, personal progress, comparison data, and challenge progress. Keeping one transactional database makes those results immediate and predictable. | |
| 6 | + | |
| 7 | +The code still has explicit module boundaries. Spring Modulith and an architecture test prevent accidental package coupling. Each module owns its entities, repositories, service logic, and HTTP entry points. | |
| 8 | + | |
| 9 | +Microservices would add network failure modes, distributed tracing, message delivery, separate migrations, and eventual consistency before the product has a scaling need. The current design keeps an extraction path without paying that cost now. | |
| 10 | + | |
| 11 | +## Runtime view | |
| 12 | + | |
| 13 | +```mermaid | |
| 14 | +flowchart LR | |
| 15 | + U[Browser] --> N[Nginx] | |
| 16 | + N --> R[React application] | |
| 17 | + N --> A[Spring Boot API] | |
| 18 | + A --> P[(PostgreSQL)] | |
| 19 | + A --> M[(Photo volume)] | |
| 20 | + A --> H[Health and OpenAPI] | |
| 21 | +``` | |
| 22 | + | |
| 23 | +Nginx serves immutable frontend assets, applies a request-size limit and basic authentication rate limiting, proxies API requests, and returns the React entry page for client routes. | |
| 24 | + | |
| 25 | +The backend is stateless at the HTTP layer. A session is a random opaque token. Only its SHA-256 hash is stored, so a database read does not reveal usable sessions. | |
| 26 | + | |
| 27 | +## Backend modules | |
| 28 | + | |
| 29 | +| Module | Owns | Depends on | | |
| 30 | +| --- | --- | --- | | |
| 31 | +| `account` | Users, password hashes, session tokens, profile privacy | `shared` | | |
| 32 | +| `catalog` | Destinations, dishes, categories, cultural importance | `shared` | | |
| 33 | +| `tasting` | Tasting journal and ownership rules | `account`, `catalog`, `media`, `shared` | | |
| 34 | +| `journey` | Trips and persisted five-bite missions | `catalog`, `tasting`, `shared` | | |
| 35 | +| `progress` | Weighted coverage, important gaps, dashboard projection | `account`, `catalog`, `tasting`, `journey` | | |
| 36 | +| `social` | Public Tasteprints, comparisons, collaborative challenges | `account`, `catalog`, `tasting`, `progress`, `shared` | | |
| 37 | +| `media` | Validated local photo storage and public file route | `account`, `shared` | | |
| 38 | +| `demo` | Optional local sample accounts and activity | Application modules through public services | | |
| 39 | +| `shared` | Clock, API errors, OpenAPI configuration | None | | |
| 40 | + | |
| 41 | +Repositories and JPA entities are package-private where another module does not need them. Modules communicate through public service methods and immutable record views. | |
| 42 | + | |
| 43 | +## Main flows | |
| 44 | + | |
| 45 | +### Record a tasting | |
| 46 | + | |
| 47 | +```text | |
| 48 | +Authenticated request | |
| 49 | + -> validate dish, date, rating, country, coordinates, and photo URL | |
| 50 | + -> verify the catalog dish exists | |
| 51 | + -> save a user-owned tasting | |
| 52 | + -> publish TastingRecorded | |
| 53 | + -> invalidate frontend queries | |
| 54 | + -> recalculate projections when they are read | |
| 55 | +``` | |
| 56 | + | |
| 57 | +Progress is currently calculated on read. The catalog has 72 dishes and this keeps writes simple. If the dataset or traffic grows, the `TastingRecorded` event is the boundary for introducing cached projections without changing the API. | |
| 58 | + | |
| 59 | +### Build a trip mission | |
| 60 | + | |
| 61 | +```text | |
| 62 | +Create trip | |
| 63 | + -> load destination dishes | |
| 64 | + -> sort untried dishes before tried dishes | |
| 65 | + -> prefer higher cultural importance | |
| 66 | + -> select distinct categories first | |
| 67 | + -> persist five mission items | |
| 68 | +``` | |
| 69 | + | |
| 70 | +A trip mission does not change after creation. This gives the user a stable plan. Completion requires both a date inside the trip and a tasting country equal to the destination code. | |
| 71 | + | |
| 72 | +### Update a group challenge | |
| 73 | + | |
| 74 | +Challenge scores are derived from all participant tastings within the challenge dates. Dish slugs are restricted to the challenge destination before weighted coverage is calculated. A member can leave. The owner can delete the challenge but cannot leave an ownerless record. | |
| 75 | + | |
| 76 | +## Data and consistency | |
| 77 | + | |
| 78 | +Flyway owns the schema and curated catalog. Hibernate uses `validate`, never automatic schema creation or mutation. Foreign keys and check constraints protect ownership relationships, date ordering, ratings, coordinates, category uniqueness, mission position, and challenge membership. | |
| 79 | + | |
| 80 | +H2 in PostgreSQL compatibility mode keeps local startup and most tests fast. A Testcontainers test starts PostgreSQL when Docker is available to verify migrations and the core write flow against the production database engine. | |
| 81 | + | |
| 82 | +Photo bytes are deliberately outside the relational database. The database stores ownership and file metadata, a tasting stores a relative upload URL, and a mounted volume stores bytes. `MediaStorage` is an interface so object storage can replace local disk without changing tasting logic. Removing a tasting removes its local photo. Account deletion removes unattached uploads as well. | |
| 83 | + | |
| 84 | +## Security and privacy | |
| 85 | + | |
| 86 | +- Passwords use BCrypt with cost 12. | |
| 87 | +- Session tokens contain 256 random bits, expire after 30 days, and are stored only as hashes. | |
| 88 | +- The API is stateless and protected by bearer authentication. | |
| 89 | +- Public profiles are opt-in and return a DTO without email or private account fields. | |
| 90 | +- Public tasting DTOs omit restaurant details, exact coordinates, and internal timestamps. | |
| 91 | +- User-owned tasting and trip lookups include the authenticated user ID. | |
| 92 | +- Challenge reads require membership. Destructive actions distinguish owner and member permissions. | |
| 93 | +- CORS is limited to configured origins. | |
| 94 | +- Photo upload accepts JPEG, PNG, or WebP, checks magic bytes, generates filenames, records ownership, rejects cross-account reuse, and prevents path traversal. | |
| 95 | +- Nginx blocks framing, disables MIME sniffing, sets a strict referrer policy, limits upload size, and rate limits login and registration in the supplied deployment. | |
| 96 | + | |
| 97 | +For a larger public deployment, put TLS and a managed rate limiter at the edge, move photos to object storage, rotate database credentials through a secret manager, and add automated backups. | |
| 98 | + | |
| 99 | +## API design | |
| 100 | + | |
| 101 | +The API is versioned under `/api/v1`. Validation and domain errors use RFC 9457 problem details. Successful create requests return `201`, deletes and logout return `204`, private missing resources return `404` where revealing ownership would leak information, and permission failures return `403`. | |
| 102 | + | |
| 103 | +OpenAPI is available at `/docs` in development and can be disabled through Springdoc configuration in a restricted production environment. | |
| 104 | + | |
| 105 | +## Extraction criteria | |
| 106 | + | |
| 107 | +A module should become a separate service only after evidence shows an independent need. Likely candidates are: | |
| 108 | + | |
| 109 | +1. Media, when object processing or moderation needs its own workers. | |
| 110 | +2. Progress, when precomputed projections require independent scaling. | |
| 111 | +3. Notifications, when challenge activity adds asynchronous email or push delivery. | |
| 112 | + | |
| 113 | +Extraction would use the existing public service and domain event boundaries. Account, catalog, tasting, and journey should remain together until independent scaling or ownership becomes more valuable than transactional simplicity. |
added frontend/.dockerignore +5 −0
| @@ -0,0 +1,5 @@ | ||
| 1 | +node_modules | |
| 2 | +dist | |
| 3 | +coverage | |
| 4 | +.idea | |
| 5 | +*.log |
added frontend/Dockerfile +12 −0
| @@ -0,0 +1,12 @@ | ||
| 1 | +FROM node:24-alpine AS build | |
| 2 | +WORKDIR /workspace | |
| 3 | +COPY package.json package-lock.json ./ | |
| 4 | +RUN npm ci | |
| 5 | +COPY . . | |
| 6 | +RUN npm run build | |
| 7 | + | |
| 8 | +FROM nginx:1.29-alpine | |
| 9 | +COPY nginx.conf /etc/nginx/conf.d/default.conf | |
| 10 | +COPY nginx-proxy.conf /etc/nginx/proxy-common.conf | |
| 11 | +COPY --from=build /workspace/dist /usr/share/nginx/html | |
| 12 | +EXPOSE 80 |
added frontend/eslint.config.js +25 −0
| @@ -0,0 +1,25 @@ | ||
| 1 | +import eslint from "@eslint/js"; | |
| 2 | +import reactHooks from "eslint-plugin-react-hooks"; | |
| 3 | +import reactRefresh from "eslint-plugin-react-refresh"; | |
| 4 | +import tseslint from "typescript-eslint"; | |
| 5 | + | |
| 6 | +export default tseslint.config( | |
| 7 | + { ignores: ["dist", "coverage"] }, | |
| 8 | + eslint.configs.recommended, | |
| 9 | + ...tseslint.configs.recommended, | |
| 10 | + { | |
| 11 | + files: ["**/*.{ts,tsx}"], | |
| 12 | + languageOptions: { | |
| 13 | + ecmaVersion: 2022 | |
| 14 | + }, | |
| 15 | + plugins: { | |
| 16 | + "react-hooks": reactHooks, | |
| 17 | + "react-refresh": reactRefresh | |
| 18 | + }, | |
| 19 | + rules: { | |
| 20 | + ...reactHooks.configs.recommended.rules, | |
| 21 | + "react-hooks/set-state-in-effect": "off", | |
| 22 | + "react-refresh/only-export-components": "off" | |
| 23 | + } | |
| 24 | + } | |
| 25 | +); |
added frontend/index.html +14 −0
| @@ -0,0 +1,14 @@ | ||
| 1 | +<!doctype html> | |
| 2 | +<html lang="en"> | |
| 3 | + <head> | |
| 4 | + <meta charset="UTF-8" /> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 6 | + <meta name="theme-color" content="#1546d8" /> | |
| 7 | + <meta name="description" content="Tasteprint turns every trip into a map of the food culture you actually experienced." /> | |
| 8 | + <title>Tasteprint</title> | |
| 9 | + </head> | |
| 10 | + <body> | |
| 11 | + <div id="root"></div> | |
| 12 | + <script type="module" src="/src/main.tsx"></script> | |
| 13 | + </body> | |
| 14 | +</html> |
added frontend/nginx-proxy.conf +8 −0
| @@ -0,0 +1,8 @@ | ||
| 1 | +proxy_pass http://backend:8080; | |
| 2 | +proxy_http_version 1.1; | |
| 3 | +proxy_set_header Host $host; | |
| 4 | +proxy_set_header X-Real-IP $remote_addr; | |
| 5 | +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | |
| 6 | +proxy_set_header X-Forwarded-Proto $scheme; | |
| 7 | +proxy_connect_timeout 5s; | |
| 8 | +proxy_read_timeout 30s; |
added frontend/nginx.conf +52 −0
| @@ -0,0 +1,52 @@ | ||
| 1 | +limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=10r/m; | |
| 2 | + | |
| 3 | +server { | |
| 4 | + listen 80; | |
| 5 | + server_name _; | |
| 6 | + root /usr/share/nginx/html; | |
| 7 | + index index.html; | |
| 8 | + client_max_body_size 7m; | |
| 9 | + | |
| 10 | + add_header X-Content-Type-Options nosniff always; | |
| 11 | + add_header X-Frame-Options DENY always; | |
| 12 | + add_header Referrer-Policy strict-origin-when-cross-origin always; | |
| 13 | + add_header Permissions-Policy "camera=(self), geolocation=(self), microphone=()" always; | |
| 14 | + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always; | |
| 15 | + | |
| 16 | + location = /healthz { | |
| 17 | + access_log off; | |
| 18 | + default_type text/plain; | |
| 19 | + return 200 "ok\n"; | |
| 20 | + } | |
| 21 | + | |
| 22 | + location = /api/v1/auth/login { | |
| 23 | + limit_req zone=auth_limit burst=5 nodelay; | |
| 24 | + include /etc/nginx/proxy-common.conf; | |
| 25 | + } | |
| 26 | + | |
| 27 | + location = /api/v1/auth/register { | |
| 28 | + limit_req zone=auth_limit burst=3 nodelay; | |
| 29 | + include /etc/nginx/proxy-common.conf; | |
| 30 | + } | |
| 31 | + | |
| 32 | + location /api/ { | |
| 33 | + include /etc/nginx/proxy-common.conf; | |
| 34 | + } | |
| 35 | + | |
| 36 | + location /uploads/ { | |
| 37 | + include /etc/nginx/proxy-common.conf; | |
| 38 | + expires 7d; | |
| 39 | + add_header Cache-Control "public, immutable"; | |
| 40 | + add_header X-Content-Type-Options nosniff always; | |
| 41 | + } | |
| 42 | + | |
| 43 | + location /assets/ { | |
| 44 | + try_files $uri =404; | |
| 45 | + expires 1y; | |
| 46 | + add_header Cache-Control "public, immutable"; | |
| 47 | + } | |
| 48 | + | |
| 49 | + location / { | |
| 50 | + try_files $uri $uri/ /index.html; | |
| 51 | + } | |
| 52 | +} |
added frontend/package-lock.json +4003 −0
Line changes are not available for this file.
added frontend/package.json +47 −0
| @@ -0,0 +1,47 @@ | ||
| 1 | +{ | |
| 2 | + "name": "tasteprint-frontend", | |
| 3 | + "private": true, | |
| 4 | + "version": "0.1.0", | |
| 5 | + "type": "module", | |
| 6 | + "scripts": { | |
| 7 | + "dev": "vite", | |
| 8 | + "build": "tsc --noEmit && vite build", | |
| 9 | + "lint": "eslint .", | |
| 10 | + "test": "vitest run", | |
| 11 | + "test:watch": "vitest", | |
| 12 | + "preview": "vite preview" | |
| 13 | + }, | |
| 14 | + "dependencies": { | |
| 15 | + "@fontsource/barlow-condensed": "5.3.0", | |
| 16 | + "@fontsource/ibm-plex-mono": "5.3.0", | |
| 17 | + "@fontsource/manrope": "5.3.0", | |
| 18 | + "@tanstack/react-query": "5.101.4", | |
| 19 | + "d3-geo": "3.1.1", | |
| 20 | + "lucide-react": "1.31.0", | |
| 21 | + "react": "19.2.8", | |
| 22 | + "react-dom": "19.2.8", | |
| 23 | + "react-router-dom": "7.18.2", | |
| 24 | + "topojson-client": "3.1.0", | |
| 25 | + "world-atlas": "2.0.2" | |
| 26 | + }, | |
| 27 | + "devDependencies": { | |
| 28 | + "@eslint/js": "10.0.1", | |
| 29 | + "@testing-library/jest-dom": "7.0.0", | |
| 30 | + "@testing-library/react": "16.3.2", | |
| 31 | + "@testing-library/user-event": "14.6.3", | |
| 32 | + "@types/d3-geo": "3.1.1", | |
| 33 | + "@types/geojson": "7946.0.16", | |
| 34 | + "@types/react": "19.2.18", | |
| 35 | + "@types/react-dom": "19.2.4", | |
| 36 | + "@types/topojson-client": "3.1.5", | |
| 37 | + "@vitejs/plugin-react": "6.0.5", | |
| 38 | + "eslint": "10.8.1", | |
| 39 | + "eslint-plugin-react-hooks": "7.1.1", | |
| 40 | + "eslint-plugin-react-refresh": "0.5.3", | |
| 41 | + "jsdom": "29.1.1", | |
| 42 | + "typescript": "5.9.3", | |
| 43 | + "typescript-eslint": "8.66.0", | |
| 44 | + "vite": "8.2.1", | |
| 45 | + "vitest": "4.1.10" | |
| 46 | + } | |
| 47 | +} |
added frontend/src/App.tsx +47 −0
| @@ -0,0 +1,47 @@ | ||
| 1 | +import { Route, Routes } from "react-router-dom"; | |
| 2 | +import { AppShell } from "./components/AppShell"; | |
| 3 | +import { ProtectedRoute } from "./components/ProtectedRoute"; | |
| 4 | +import { QuickTasteProvider } from "./components/QuickTaste"; | |
| 5 | +import { AuthPage } from "./pages/AuthPage"; | |
| 6 | +import { ChallengeDetailsPage } from "./pages/ChallengeDetailsPage"; | |
| 7 | +import { ChallengesPage } from "./pages/ChallengesPage"; | |
| 8 | +import { ComparePage } from "./pages/ComparePage"; | |
| 9 | +import { DashboardPage } from "./pages/DashboardPage"; | |
| 10 | +import { DestinationPage } from "./pages/DestinationPage"; | |
| 11 | +import { ExplorePage } from "./pages/ExplorePage"; | |
| 12 | +import { JoinChallengePage } from "./pages/JoinChallengePage"; | |
| 13 | +import { LandingPage } from "./pages/LandingPage"; | |
| 14 | +import { NotFoundPage } from "./pages/NotFoundPage"; | |
| 15 | +import { ProfilePage } from "./pages/ProfilePage"; | |
| 16 | +import { PublicTasteprintPage } from "./pages/PublicTasteprintPage"; | |
| 17 | +import { TastingsPage } from "./pages/TastingsPage"; | |
| 18 | +import { TripDetailsPage } from "./pages/TripDetailsPage"; | |
| 19 | +import { TripsPage } from "./pages/TripsPage"; | |
| 20 | + | |
| 21 | +export function App() { | |
| 22 | + return ( | |
| 23 | + <Routes> | |
| 24 | + <Route path="/" element={<LandingPage />} /> | |
| 25 | + <Route path="/login" element={<AuthPage mode="login" />} /> | |
| 26 | + <Route path="/register" element={<AuthPage mode="register" />} /> | |
| 27 | + <Route path="/t/:shareSlug" element={<PublicTasteprintPage />} /> | |
| 28 | + | |
| 29 | + <Route element={<ProtectedRoute />}> | |
| 30 | + <Route path="/join/:code" element={<JoinChallengePage />} /> | |
| 31 | + <Route path="/app" element={<QuickTasteProvider><AppShell /></QuickTasteProvider>}> | |
| 32 | + <Route index element={<DashboardPage />} /> | |
| 33 | + <Route path="explore" element={<ExplorePage />} /> | |
| 34 | + <Route path="destinations/:code" element={<DestinationPage />} /> | |
| 35 | + <Route path="tastings" element={<TastingsPage />} /> | |
| 36 | + <Route path="trips" element={<TripsPage />} /> | |
| 37 | + <Route path="trips/:tripId" element={<TripDetailsPage />} /> | |
| 38 | + <Route path="challenges" element={<ChallengesPage />} /> | |
| 39 | + <Route path="challenges/:challengeId" element={<ChallengeDetailsPage />} /> | |
| 40 | + <Route path="profile" element={<ProfilePage />} /> | |
| 41 | + <Route path="compare/:shareSlug" element={<ComparePage />} /> | |
| 42 | + </Route> | |
| 43 | + </Route> | |
| 44 | + <Route path="*" element={<NotFoundPage />} /> | |
| 45 | + </Routes> | |
| 46 | + ); | |
| 47 | +} |
added frontend/src/components/AppShell.tsx +81 −0
| @@ -0,0 +1,81 @@ | ||
| 1 | +import { NavLink, Outlet, useNavigate } from "react-router-dom"; | |
| 2 | +import { Compass, Globe2, LogOut, Plane, Plus, Stamp, UserRound, UsersRound } from "lucide-react"; | |
| 3 | +import { Logo } from "./Logo"; | |
| 4 | +import { Avatar } from "./Ui"; | |
| 5 | +import { useAuth } from "../lib/auth"; | |
| 6 | +import { useQuickTaste } from "./QuickTaste"; | |
| 7 | + | |
| 8 | +const navigation = [ | |
| 9 | + { to: "/app", end: true, label: "Overview", icon: Compass }, | |
| 10 | + { to: "/app/explore", label: "Explore", icon: Globe2 }, | |
| 11 | + { to: "/app/tastings", label: "Tastings", icon: Stamp }, | |
| 12 | + { to: "/app/trips", label: "Trips", icon: Plane }, | |
| 13 | + { to: "/app/challenges", label: "Together", icon: UsersRound } | |
| 14 | +]; | |
| 15 | + | |
| 16 | +export function AppShell() { | |
| 17 | + const { user, logout } = useAuth(); | |
| 18 | + const { openTaste } = useQuickTaste(); | |
| 19 | + const navigate = useNavigate(); | |
| 20 | + | |
| 21 | + return ( | |
| 22 | + <div className="app-frame"> | |
| 23 | + <a className="skip-link" href="#main-content">Skip to content</a> | |
| 24 | + <aside className="sidebar"> | |
| 25 | + <Logo to="/app" /> | |
| 26 | + <button className="button quick-stamp-button" type="button" onClick={() => openTaste()}> | |
| 27 | + <Plus size={18} /> Log a taste | |
| 28 | + </button> | |
| 29 | + <nav className="side-nav" aria-label="Main navigation"> | |
| 30 | + {navigation.map(item => { | |
| 31 | + const Icon = item.icon; | |
| 32 | + return ( | |
| 33 | + <NavLink key={item.to} to={item.to} end={item.end}> | |
| 34 | + <Icon size={19} /> | |
| 35 | + <span>{item.label}</span> | |
| 36 | + </NavLink> | |
| 37 | + ); | |
| 38 | + })} | |
| 39 | + </nav> | |
| 40 | + {user && ( | |
| 41 | + <div className="sidebar-account"> | |
| 42 | + <button type="button" className="account-link" onClick={() => navigate("/app/profile")}> | |
| 43 | + <Avatar name={user.displayName} url={user.avatarUrl} size="small" /> | |
| 44 | + <span> | |
| 45 | + <strong>{user.displayName}</strong> | |
| 46 | + <small>View passport</small> | |
| 47 | + </span> | |
| 48 | + <UserRound size={16} /> | |
| 49 | + </button> | |
| 50 | + <button className="logout-button" type="button" onClick={() => void logout()} aria-label="Sign out"> | |
| 51 | + <LogOut size={17} /> | |
| 52 | + </button> | |
| 53 | + </div> | |
| 54 | + )} | |
| 55 | + </aside> | |
| 56 | + | |
| 57 | + <header className="mobile-header"> | |
| 58 | + <Logo to="/app" /> | |
| 59 | + <button className="icon-button icon-button-primary" type="button" onClick={() => openTaste()} aria-label="Log a taste"> | |
| 60 | + <Plus size={21} /> | |
| 61 | + </button> | |
| 62 | + </header> | |
| 63 | + | |
| 64 | + <main id="main-content" className="app-main"> | |
| 65 | + <Outlet /> | |
| 66 | + </main> | |
| 67 | + | |
| 68 | + <nav className="mobile-nav" aria-label="Mobile navigation"> | |
| 69 | + {navigation.map(item => { | |
| 70 | + const Icon = item.icon; | |
| 71 | + return ( | |
| 72 | + <NavLink key={item.to} to={item.to} end={item.end}> | |
| 73 | + <Icon size={20} /> | |
| 74 | + <span>{item.label}</span> | |
| 75 | + </NavLink> | |
| 76 | + ); | |
| 77 | + })} | |
| 78 | + </nav> | |
| 79 | + </div> | |
| 80 | + ); | |
| 81 | +} |
added frontend/src/components/ChallengeFormDialog.tsx +95 −0
| @@ -0,0 +1,95 @@ | ||
| 1 | +import { useState, type FormEvent } from "react"; | |
| 2 | +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | +import { LoaderCircle, X } from "lucide-react"; | |
| 4 | +import { ApiError, api, jsonBody } from "../lib/api"; | |
| 5 | +import { useModalDialog } from "../lib/useModalDialog"; | |
| 6 | +import type { Challenge, CreateChallengeInput, Destination } from "../types"; | |
| 7 | + | |
| 8 | +function dateAfter(days: number) { | |
| 9 | + const date = new Date(); | |
| 10 | + date.setDate(date.getDate() + days); | |
| 11 | + return new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 10); | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function ChallengeFormDialog({ onClose, onCreated }: { | |
| 15 | + onClose: () => void; | |
| 16 | + onCreated: (challenge: Challenge) => void; | |
| 17 | +}) { | |
| 18 | + const dialogRef = useModalDialog(onClose); | |
| 19 | + const queryClient = useQueryClient(); | |
| 20 | + const [title, setTitle] = useState(""); | |
| 21 | + const [destinationCode, setDestinationCode] = useState(""); | |
| 22 | + const [startsOn, setStartsOn] = useState(dateAfter(0)); | |
| 23 | + const [endsOn, setEndsOn] = useState(dateAfter(30)); | |
| 24 | + const [error, setError] = useState(""); | |
| 25 | + const destinations = useQuery({ | |
| 26 | + queryKey: ["destinations"], | |
| 27 | + queryFn: () => api<Destination[]>("/api/v1/catalog/destinations") | |
| 28 | + }); | |
| 29 | + | |
| 30 | + const selectedDestinationCode = destinationCode || destinations.data?.[0]?.code || ""; | |
| 31 | + | |
| 32 | + const create = useMutation({ | |
| 33 | + mutationFn: () => { | |
| 34 | + const body: CreateChallengeInput = { title: title.trim(), destinationCode: selectedDestinationCode, startsOn, endsOn }; | |
| 35 | + return api<Challenge>("/api/v1/challenges", { method: "POST", ...jsonBody(body) }); | |
| 36 | + }, | |
| 37 | + onSuccess: async challenge => { | |
| 38 | + await queryClient.invalidateQueries({ queryKey: ["challenges"] }); | |
| 39 | + onCreated(challenge); | |
| 40 | + }, | |
| 41 | + onError: caught => setError(caught instanceof ApiError ? caught.message : "The challenge could not be created.") | |
| 42 | + }); | |
| 43 | + | |
| 44 | + function submit(event: FormEvent) { | |
| 45 | + event.preventDefault(); | |
| 46 | + setError(""); | |
| 47 | + create.mutate(); | |
| 48 | + } | |
| 49 | + | |
| 50 | + return ( | |
| 51 | + <div className="dialog-backdrop" role="presentation" onMouseDown={event => { | |
| 52 | + if (event.currentTarget === event.target) onClose(); | |
| 53 | + }}> | |
| 54 | + <section ref={dialogRef} className="small-dialog" role="dialog" aria-modal="true" aria-labelledby="challenge-dialog-title"> | |
| 55 | + <header className="dialog-header"> | |
| 56 | + <div> | |
| 57 | + <span className="eyebrow">Shared food map</span> | |
| 58 | + <h2 id="challenge-dialog-title">Start a tasting challenge</h2> | |
| 59 | + </div> | |
| 60 | + <button className="icon-button" type="button" onClick={onClose} aria-label="Close"><X size={20} /></button> | |
| 61 | + </header> | |
| 62 | + <form className="dialog-form" onSubmit={submit}> | |
| 63 | + <label> | |
| 64 | + <span>Challenge name</span> | |
| 65 | + <input value={title} onChange={event => setTitle(event.target.value)} placeholder="Tokyo table quest" maxLength={100} required data-initial-focus /> | |
| 66 | + </label> | |
| 67 | + <label> | |
| 68 | + <span>Food culture</span> | |
| 69 | + <select value={selectedDestinationCode} onChange={event => setDestinationCode(event.target.value)} required> | |
| 70 | + {destinations.data?.map(item => <option key={item.code} value={item.code}>{item.name}</option>)} | |
| 71 | + </select> | |
| 72 | + </label> | |
| 73 | + <div className="form-grid form-grid-two"> | |
| 74 | + <label> | |
| 75 | + <span>Starts</span> | |
| 76 | + <input type="date" value={startsOn} onChange={event => setStartsOn(event.target.value)} required /> | |
| 77 | + </label> | |
| 78 | + <label> | |
| 79 | + <span>Ends</span> | |
| 80 | + <input type="date" value={endsOn} min={startsOn} onChange={event => setEndsOn(event.target.value)} required /> | |
| 81 | + </label> | |
| 82 | + </div> | |
| 83 | + <p className="form-hint">Everyone contributes their own logged dishes. The group fills one shared map.</p> | |
| 84 | + {error && <p className="form-error" role="alert">{error}</p>} | |
| 85 | + <footer className="dialog-actions"> | |
| 86 | + <button className="button button-ghost" type="button" onClick={onClose}>Cancel</button> | |
| 87 | + <button className="button button-primary" type="submit" disabled={create.isPending || destinations.isLoading}> | |
| 88 | + {create.isPending && <LoaderCircle className="spin" size={17} />} Create challenge | |
| 89 | + </button> | |
| 90 | + </footer> | |
| 91 | + </form> | |
| 92 | + </section> | |
| 93 | + </div> | |
| 94 | + ); | |
| 95 | +} |
added frontend/src/components/DishCard.tsx +39 −0
| @@ -0,0 +1,39 @@ | ||
| 1 | +import { Check, Flame, Leaf, Plus } from "lucide-react"; | |
| 2 | +import type { Dish } from "../types"; | |
| 3 | +import { useQuickTaste } from "./QuickTaste"; | |
| 4 | + | |
| 5 | +export function DishCard({ dish, tried = false, compact = false }: { | |
| 6 | + dish: Dish; | |
| 7 | + tried?: boolean; | |
| 8 | + compact?: boolean; | |
| 9 | +}) { | |
| 10 | + const { openTaste } = useQuickTaste(); | |
| 11 | + return ( | |
| 12 | + <article className={`dish-card ${tried ? "dish-card-tried" : ""} ${compact ? "dish-card-compact" : ""}`}> | |
| 13 | + <div className="dish-card-band" style={{ backgroundColor: dish.destinationAccent }}> | |
| 14 | + <span>{dish.destinationCode}</span> | |
| 15 | + <span>{dish.categoryLabel}</span> | |
| 16 | + </div> | |
| 17 | + <div className="dish-card-body"> | |
| 18 | + <div className="dish-card-title"> | |
| 19 | + <div> | |
| 20 | + <h3>{dish.name}</h3> | |
| 21 | + {dish.localName && dish.localName !== dish.name && <p>{dish.localName}</p>} | |
| 22 | + </div> | |
| 23 | + {tried && <span className="tried-seal" title="Tried"><Check size={16} /></span>} | |
| 24 | + </div> | |
| 25 | + {!compact && <p className="dish-description">{dish.description}</p>} | |
| 26 | + <div className="dish-meta"> | |
| 27 | + {dish.vegetarian && <span><Leaf size={13} /> Vegetarian</span>} | |
| 28 | + {dish.spicyLevel > 0 && <span><Flame size={13} /> Heat {dish.spicyLevel}/3</span>} | |
| 29 | + <span>Importance {dish.importance}/5</span> | |
| 30 | + </div> | |
| 31 | + {!tried && ( | |
| 32 | + <button className="text-button" type="button" onClick={() => openTaste({ dish })}> | |
| 33 | + <Plus size={15} /> Log this dish | |
| 34 | + </button> | |
| 35 | + )} | |
| 36 | + </div> | |
| 37 | + </article> | |
| 38 | + ); | |
| 39 | +} |
added frontend/src/components/Logo.tsx +15 −0
| @@ -0,0 +1,15 @@ | ||
| 1 | +import { Link } from "react-router-dom"; | |
| 2 | + | |
| 3 | +export function Logo({ to = "/" }: { to?: string }) { | |
| 4 | + return ( | |
| 5 | + <Link className="brand" to={to} aria-label="Tasteprint home"> | |
| 6 | + <span className="brand-mark" aria-hidden="true"> | |
| 7 | + <span>TP</span> | |
| 8 | + </span> | |
| 9 | + <span className="brand-word"> | |
| 10 | + <strong>Taste</strong> | |
| 11 | + <strong>print</strong> | |
| 12 | + </span> | |
| 13 | + </Link> | |
| 14 | + ); | |
| 15 | +} |
added frontend/src/components/ProgressRing.tsx +25 −0
| @@ -0,0 +1,25 @@ | ||
| 1 | +import type { CSSProperties } from "react"; | |
| 2 | + | |
| 3 | +interface ProgressRingProps { | |
| 4 | + value: number; | |
| 5 | + size?: "small" | "medium" | "large"; | |
| 6 | + label?: string; | |
| 7 | + light?: boolean; | |
| 8 | +} | |
| 9 | + | |
| 10 | +export function ProgressRing({ value, size = "medium", label = "coverage", light = false }: ProgressRingProps) { | |
| 11 | + const safeValue = Math.min(100, Math.max(0, value)); | |
| 12 | + return ( | |
| 13 | + <div | |
| 14 | + className={`progress-ring progress-ring-${size} ${light ? "progress-ring-light" : ""}`} | |
| 15 | + style={{ "--progress": `${safeValue * 3.6}deg` } as CSSProperties} | |
| 16 | + role="img" | |
| 17 | + aria-label={`${safeValue}% ${label}`} | |
| 18 | + > | |
| 19 | + <div> | |
| 20 | + <strong>{safeValue}</strong> | |
| 21 | + <span>%</span> | |
| 22 | + </div> | |
| 23 | + </div> | |
| 24 | + ); | |
| 25 | +} |
added frontend/src/components/ProtectedRoute.test.tsx +72 −0
| @@ -0,0 +1,72 @@ | ||
| 1 | +import { afterEach, expect, it, vi } from "vitest"; | |
| 2 | +import { render, screen, waitFor } from "@testing-library/react"; | |
| 3 | +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; | |
| 4 | +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; | |
| 5 | +import type { ReactNode } from "react"; | |
| 6 | +import { AuthProvider } from "../lib/auth"; | |
| 7 | +import { ProtectedRoute } from "./ProtectedRoute"; | |
| 8 | + | |
| 9 | +function LoginLocation() { | |
| 10 | + const location = useLocation(); | |
| 11 | + return <div>Sign in at {location.pathname + location.search}</div>; | |
| 12 | +} | |
| 13 | + | |
| 14 | +function TestProviders({ children }: { children: ReactNode }) { | |
| 15 | + return <QueryClientProvider client={new QueryClient()}>{children}</QueryClientProvider>; | |
| 16 | +} | |
| 17 | + | |
| 18 | +afterEach(() => { | |
| 19 | + localStorage.clear(); | |
| 20 | + vi.unstubAllGlobals(); | |
| 21 | +}); | |
| 22 | + | |
| 23 | +it("sends a signed-out visitor to login and keeps the intended route", async () => { | |
| 24 | + render( | |
| 25 | + <TestProviders> | |
| 26 | + <MemoryRouter initialEntries={["/app/trips/123"]}> | |
| 27 | + <AuthProvider> | |
| 28 | + <Routes> | |
| 29 | + <Route path="/login" element={<LoginLocation />} /> | |
| 30 | + <Route element={<ProtectedRoute />}> | |
| 31 | + <Route path="/app/trips/:id" element={<div>Private trip</div>} /> | |
| 32 | + </Route> | |
| 33 | + </Routes> | |
| 34 | + </AuthProvider> | |
| 35 | + </MemoryRouter> | |
| 36 | + </TestProviders> | |
| 37 | + ); | |
| 38 | + | |
| 39 | + expect(await screen.findByText("Sign in at /login?next=%2Fapp%2Ftrips%2F123")).toBeInTheDocument(); | |
| 40 | +}); | |
| 41 | + | |
| 42 | +it("opens private content when the stored token resolves to a user", async () => { | |
| 43 | + localStorage.setItem("tasteprint.session", "valid-token"); | |
| 44 | + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ | |
| 45 | + id: "user-1", | |
| 46 | + displayName: "Rasmus", | |
| 47 | + email: "r@example.com", | |
| 48 | + shareSlug: "rasmus-1234", | |
| 49 | + homeCity: "Tallinn", | |
| 50 | + homeCountryCode: "EE", | |
| 51 | + bio: null, | |
| 52 | + avatarUrl: null, | |
| 53 | + profilePublic: true, | |
| 54 | + memberSince: "2026-01-01T00:00:00Z" | |
| 55 | + }), { status: 200, headers: { "Content-Type": "application/json" } }))); | |
| 56 | + | |
| 57 | + render( | |
| 58 | + <TestProviders> | |
| 59 | + <MemoryRouter initialEntries={["/app"]}> | |
| 60 | + <AuthProvider> | |
| 61 | + <Routes> | |
| 62 | + <Route element={<ProtectedRoute />}> | |
| 63 | + <Route path="/app" element={<div>Private dashboard</div>} /> | |
| 64 | + </Route> | |
| 65 | + </Routes> | |
| 66 | + </AuthProvider> | |
| 67 | + </MemoryRouter> | |
| 68 | + </TestProviders> | |
| 69 | + ); | |
| 70 | + | |
| 71 | + await waitFor(() => expect(screen.getByText("Private dashboard")).toBeInTheDocument()); | |
| 72 | +}); |
added frontend/src/components/ProtectedRoute.tsx +16 −0
| @@ -0,0 +1,16 @@ | ||
| 1 | +import { Navigate, Outlet, useLocation } from "react-router-dom"; | |
| 2 | +import { useAuth } from "../lib/auth"; | |
| 3 | +import { LoadingScreen } from "./Ui"; | |
| 4 | + | |
| 5 | +export function ProtectedRoute() { | |
| 6 | + const { user, loading } = useAuth(); | |
| 7 | + const location = useLocation(); | |
| 8 | + | |
| 9 | + if (loading) { | |
| 10 | + return <LoadingScreen />; | |
| 11 | + } | |
| 12 | + if (!user) { | |
| 13 | + return <Navigate to={`/login?next=${encodeURIComponent(location.pathname + location.search)}`} replace />; | |
| 14 | + } | |
| 15 | + return <Outlet />; | |
| 16 | +} |
added frontend/src/components/QuickTaste.tsx +44 −0
| @@ -0,0 +1,44 @@ | ||
| 1 | +import { createContext, useContext, useMemo, useState, type ReactNode } from "react"; | |
| 2 | +import type { Dish, Tasting } from "../types"; | |
| 3 | +import { TasteModal } from "./TasteModal"; | |
| 4 | + | |
| 5 | +export interface TastePayload { | |
| 6 | + dish?: Dish; | |
| 7 | + tasting?: Tasting; | |
| 8 | +} | |
| 9 | + | |
| 10 | +interface QuickTasteContextValue { | |
| 11 | + openTaste: (payload?: TastePayload) => void; | |
| 12 | + closeTaste: () => void; | |
| 13 | +} | |
| 14 | + | |
| 15 | +const QuickTasteContext = createContext<QuickTasteContextValue | null>(null); | |
| 16 | + | |
| 17 | +export function QuickTasteProvider({ children }: { children: ReactNode }) { | |
| 18 | + const [payload, setPayload] = useState<TastePayload | null>(null); | |
| 19 | + const value = useMemo(() => ({ | |
| 20 | + openTaste: (next: TastePayload = {}) => setPayload(next), | |
| 21 | + closeTaste: () => setPayload(null) | |
| 22 | + }), []); | |
| 23 | + | |
| 24 | + return ( | |
| 25 | + <QuickTasteContext.Provider value={value}> | |
| 26 | + {children} | |
| 27 | + {payload && ( | |
| 28 | + <TasteModal | |
| 29 | + key={payload.tasting?.id ?? payload.dish?.slug ?? "new"} | |
| 30 | + payload={payload} | |
| 31 | + onClose={() => setPayload(null)} | |
| 32 | + /> | |
| 33 | + )} | |
| 34 | + </QuickTasteContext.Provider> | |
| 35 | + ); | |
| 36 | +} | |
| 37 | + | |
| 38 | +export function useQuickTaste(): QuickTasteContextValue { | |
| 39 | + const context = useContext(QuickTasteContext); | |
| 40 | + if (!context) { | |
| 41 | + throw new Error("useQuickTaste must be used inside QuickTasteProvider"); | |
| 42 | + } | |
| 43 | + return context; | |
| 44 | +} |
added frontend/src/components/TasteModal.tsx +237 −0
| @@ -0,0 +1,237 @@ | ||
| 1 | +import { useEffect, useMemo, useState, type FormEvent } from "react"; | |
| 2 | +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | +import { Camera, Check, LoaderCircle, MapPin, X } from "lucide-react"; | |
| 4 | +import { api, ApiError, jsonBody } from "../lib/api"; | |
| 5 | +import { todayInput } from "../lib/format"; | |
| 6 | +import { useAuth } from "../lib/auth"; | |
| 7 | +import { useModalDialog } from "../lib/useModalDialog"; | |
| 8 | +import type { Destination, DestinationDetails, SaveTastingInput, Tasting } from "../types"; | |
| 9 | +import type { TastePayload } from "./QuickTaste"; | |
| 10 | + | |
| 11 | +interface TasteModalProps { | |
| 12 | + payload: TastePayload; | |
| 13 | + onClose: () => void; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export function TasteModal({ payload, onClose }: TasteModalProps) { | |
| 17 | + const dialogRef = useModalDialog(onClose); | |
| 18 | + const { user } = useAuth(); | |
| 19 | + const queryClient = useQueryClient(); | |
| 20 | + const editing = payload.tasting; | |
| 21 | + const initialDish = editing?.dish ?? payload.dish; | |
| 22 | + const [destinationCode, setDestinationCode] = useState(initialDish?.destinationCode ?? ""); | |
| 23 | + const [dishSlug, setDishSlug] = useState(initialDish?.slug ?? ""); | |
| 24 | + const [restaurantName, setRestaurantName] = useState(editing?.restaurantName ?? ""); | |
| 25 | + const [city, setCity] = useState(editing?.city ?? user?.homeCity ?? ""); | |
| 26 | + const [countryCode, setCountryCode] = useState(editing?.countryCode ?? user?.homeCountryCode ?? ""); | |
| 27 | + const [tastedOn, setTastedOn] = useState(editing?.tastedOn ?? todayInput()); | |
| 28 | + const [rating, setRating] = useState(editing?.rating ?? 4); | |
| 29 | + const [note, setNote] = useState(editing?.note ?? ""); | |
| 30 | + const [photoUrl, setPhotoUrl] = useState(editing?.photoUrl ?? ""); | |
| 31 | + const [photo, setPhoto] = useState<File | null>(null); | |
| 32 | + const [locationLoading, setLocationLoading] = useState(false); | |
| 33 | + const [coordinates, setCoordinates] = useState<{ latitude: number; longitude: number } | null>( | |
| 34 | + editing?.latitude != null && editing.longitude != null | |
| 35 | + ? { latitude: editing.latitude, longitude: editing.longitude } | |
| 36 | + : null | |
| 37 | + ); | |
| 38 | + const [error, setError] = useState(""); | |
| 39 | + | |
| 40 | + const destinations = useQuery({ | |
| 41 | + queryKey: ["destinations"], | |
| 42 | + queryFn: () => api<Destination[]>("/api/v1/catalog/destinations") | |
| 43 | + }); | |
| 44 | + const selectedDestinationCode = destinationCode | |
| 45 | + || destinations.data?.find(item => item.code === user?.homeCountryCode)?.code | |
| 46 | + || destinations.data?.[0]?.code | |
| 47 | + || ""; | |
| 48 | + const destination = useQuery({ | |
| 49 | + queryKey: ["destination", selectedDestinationCode], | |
| 50 | + queryFn: () => api<DestinationDetails>(`/api/v1/catalog/destinations/${selectedDestinationCode}`), | |
| 51 | + enabled: Boolean(selectedDestinationCode) | |
| 52 | + }); | |
| 53 | + const selectedDishSlug = destination.data?.dishes.some(dish => dish.slug === dishSlug) | |
| 54 | + ? dishSlug | |
| 55 | + : destination.data?.dishes[0]?.slug ?? ""; | |
| 56 | + const selectedCountryCode = countryCode || selectedDestinationCode; | |
| 57 | + | |
| 58 | + const preview = useMemo(() => photo ? URL.createObjectURL(photo) : photoUrl || null, [photo, photoUrl]); | |
| 59 | + useEffect(() => () => { | |
| 60 | + if (preview?.startsWith("blob:")) URL.revokeObjectURL(preview); | |
| 61 | + }, [preview]); | |
| 62 | + | |
| 63 | + const save = useMutation({ | |
| 64 | + mutationFn: async () => { | |
| 65 | + let storedPhotoUrl = photoUrl || null; | |
| 66 | + if (photo) { | |
| 67 | + const data = new FormData(); | |
| 68 | + data.append("file", photo); | |
| 69 | + storedPhotoUrl = (await api<MediaUpload>("/api/v1/media", { method: "POST", body: data })).url; | |
| 70 | + } | |
| 71 | + const body: SaveTastingInput = { | |
| 72 | + dishSlug: selectedDishSlug, | |
| 73 | + restaurantName: restaurantName.trim() || null, | |
| 74 | + city: city.trim(), | |
| 75 | + countryCode: selectedCountryCode.trim().toUpperCase(), | |
| 76 | + tastedOn, | |
| 77 | + rating, | |
| 78 | + note: note.trim() || null, | |
| 79 | + photoUrl: storedPhotoUrl, | |
| 80 | + latitude: coordinates?.latitude ?? null, | |
| 81 | + longitude: coordinates?.longitude ?? null | |
| 82 | + }; | |
| 83 | + return api<Tasting>(editing ? `/api/v1/tastings/${editing.id}` : "/api/v1/tastings", { | |
| 84 | + method: editing ? "PUT" : "POST", | |
| 85 | + ...jsonBody(body) | |
| 86 | + }); | |
| 87 | + }, | |
| 88 | + onSuccess: async () => { | |
| 89 | + await queryClient.invalidateQueries(); | |
| 90 | + onClose(); | |
| 91 | + }, | |
| 92 | + onError: caught => setError(caught instanceof ApiError ? caught.message : "The tasting could not be saved.") | |
| 93 | + }); | |
| 94 | + | |
| 95 | + function submit(event: FormEvent) { | |
| 96 | + event.preventDefault(); | |
| 97 | + setError(""); | |
| 98 | + if (!selectedDishSlug || !city.trim() || !selectedCountryCode.trim()) { | |
| 99 | + setError("Choose a dish and add where you tasted it."); | |
| 100 | + return; | |
| 101 | + } | |
| 102 | + save.mutate(); | |
| 103 | + } | |
| 104 | + | |
| 105 | + function useLocation() { | |
| 106 | + if (!navigator.geolocation) { | |
| 107 | + setError("Location is not available in this browser."); | |
| 108 | + return; | |
| 109 | + } | |
| 110 | + setLocationLoading(true); | |
| 111 | + navigator.geolocation.getCurrentPosition( | |
| 112 | + position => { | |
| 113 | + setCoordinates({ latitude: position.coords.latitude, longitude: position.coords.longitude }); | |
| 114 | + setLocationLoading(false); | |
| 115 | + }, | |
| 116 | + () => { | |
| 117 | + setError("Location permission was not granted. You can still save the tasting."); | |
| 118 | + setLocationLoading(false); | |
| 119 | + }, | |
| 120 | + { enableHighAccuracy: false, timeout: 8000 } | |
| 121 | + ); | |
| 122 | + } | |
| 123 | + | |
| 124 | + return ( | |
| 125 | + <div className="dialog-backdrop" role="presentation" onMouseDown={event => { | |
| 126 | + if (event.currentTarget === event.target) onClose(); | |
| 127 | + }}> | |
| 128 | + <section ref={dialogRef} className="taste-dialog" role="dialog" aria-modal="true" aria-labelledby="taste-dialog-title"> | |
| 129 | + <header className="dialog-header"> | |
| 130 | + <div> | |
| 131 | + <span className="eyebrow">New passport mark</span> | |
| 132 | + <h2 id="taste-dialog-title">{editing ? "Edit this taste" : "What did you taste?"}</h2> | |
| 133 | + </div> | |
| 134 | + <button className="icon-button" type="button" onClick={onClose} aria-label="Close"> | |
| 135 | + <X size={20} /> | |
| 136 | + </button> | |
| 137 | + </header> | |
| 138 | + | |
| 139 | + <form onSubmit={submit} className="dialog-form"> | |
| 140 | + <div className="form-grid form-grid-two"> | |
| 141 | + <label> | |
| 142 | + <span>Food culture</span> | |
| 143 | + <select data-initial-focus value={selectedDestinationCode} onChange={event => { | |
| 144 | + setDestinationCode(event.target.value); | |
| 145 | + setCountryCode(event.target.value); | |
| 146 | + setDishSlug(""); | |
| 147 | + }} required> | |
| 148 | + <option value="">Choose a country</option> | |
| 149 | + {destinations.data?.map(item => <option key={item.code} value={item.code}>{item.name}</option>)} | |
| 150 | + </select> | |
| 151 | + </label> | |
| 152 | + <label> | |
| 153 | + <span>Dish</span> | |
| 154 | + <select value={selectedDishSlug} onChange={event => setDishSlug(event.target.value)} required disabled={!destination.data}> | |
| 155 | + <option value="">Choose a dish</option> | |
| 156 | + {destination.data?.dishes.map(dish => <option key={dish.slug} value={dish.slug}>{dish.name}</option>)} | |
| 157 | + </select> | |
| 158 | + </label> | |
| 159 | + </div> | |
| 160 | + | |
| 161 | + <div className="form-grid form-grid-two"> | |
| 162 | + <label> | |
| 163 | + <span>City</span> | |
| 164 | + <input value={city} onChange={event => setCity(event.target.value)} maxLength={100} placeholder="Tallinn" required /> | |
| 165 | + </label> | |
| 166 | + <label> | |
| 167 | + <span>Country code</span> | |
| 168 | + <input value={selectedCountryCode} onChange={event => setCountryCode(event.target.value.toUpperCase())} | |
| 169 | + maxLength={2} pattern="[A-Za-z]{2}" placeholder="EE" required /> | |
| 170 | + </label> | |
| 171 | + </div> | |
| 172 | + | |
| 173 | + <div className="form-grid form-grid-two"> | |
| 174 | + <label> | |
| 175 | + <span>Date</span> | |
| 176 | + <input type="date" value={tastedOn} max={todayInput()} onChange={event => setTastedOn(event.target.value)} required /> | |
| 177 | + </label> | |
| 178 | + <label> | |
| 179 | + <span>Place, optional</span> | |
| 180 | + <input value={restaurantName} onChange={event => setRestaurantName(event.target.value)} | |
| 181 | + maxLength={160} placeholder="Restaurant or market" /> | |
| 182 | + </label> | |
| 183 | + </div> | |
| 184 | + | |
| 185 | + <fieldset className="rating-field"> | |
| 186 | + <legend>Your rating</legend> | |
| 187 | + <div className="rating-options"> | |
| 188 | + {[1, 2, 3, 4, 5].map(value => ( | |
| 189 | + <button key={value} className={rating === value ? "rating-active" : ""} type="button" | |
| 190 | + onClick={() => setRating(value)} aria-label={`${value} out of 5`}> | |
| 191 | + {value} | |
| 192 | + </button> | |
| 193 | + ))} | |
| 194 | + </div> | |
| 195 | + </fieldset> | |
| 196 | + | |
| 197 | + <label> | |
| 198 | + <span>Taste note, optional</span> | |
| 199 | + <textarea value={note} onChange={event => setNote(event.target.value)} maxLength={500} | |
| 200 | + rows={3} placeholder="What will you remember about it?" /> | |
| 201 | + </label> | |
| 202 | + | |
| 203 | + <div className="photo-location-row"> | |
| 204 | + <div className="photo-control"> | |
| 205 | + <label className="photo-picker"> | |
| 206 | + {preview ? <img src={preview} alt="Selected tasting" /> : <Camera size={24} />} | |
| 207 | + <span>{preview ? "Change photo" : "Add a photo"}</span> | |
| 208 | + <input type="file" accept="image/jpeg,image/png,image/webp" onChange={event => setPhoto(event.target.files?.[0] ?? null)} /> | |
| 209 | + </label> | |
| 210 | + {preview && <button className="remove-photo-button" type="button" onClick={() => { setPhoto(null); setPhotoUrl(""); }}>Remove photo</button>} | |
| 211 | + </div> | |
| 212 | + <button className={`location-button ${coordinates ? "location-set" : ""}`} type="button" onClick={useLocation}> | |
| 213 | + {locationLoading ? <LoaderCircle className="spin" size={18} /> : coordinates ? <Check size={18} /> : <MapPin size={18} />} | |
| 214 | + {coordinates ? "Location attached" : "Attach location"} | |
| 215 | + </button> | |
| 216 | + </div> | |
| 217 | + | |
| 218 | + {error && <p className="form-error" role="alert">{error}</p>} | |
| 219 | + | |
| 220 | + <footer className="dialog-actions"> | |
| 221 | + <button className="button button-ghost" type="button" onClick={onClose}>Cancel</button> | |
| 222 | + <button className="button button-primary" type="submit" disabled={save.isPending}> | |
| 223 | + {save.isPending && <LoaderCircle className="spin" size={17} />} | |
| 224 | + {editing ? "Save changes" : "Stamp this dish"} | |
| 225 | + </button> | |
| 226 | + </footer> | |
| 227 | + </form> | |
| 228 | + </section> | |
| 229 | + </div> | |
| 230 | + ); | |
| 231 | +} | |
| 232 | + | |
| 233 | +interface MediaUpload { | |
| 234 | + url: string; | |
| 235 | + contentType: string; | |
| 236 | + size: number; | |
| 237 | +} |
added frontend/src/components/TripFormDialog.tsx +98 −0
| @@ -0,0 +1,98 @@ | ||
| 1 | +import { useState, type FormEvent } from "react"; | |
| 2 | +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | +import { LoaderCircle, X } from "lucide-react"; | |
| 4 | +import { api, ApiError, jsonBody } from "../lib/api"; | |
| 5 | +import { todayInput } from "../lib/format"; | |
| 6 | +import { useModalDialog } from "../lib/useModalDialog"; | |
| 7 | +import type { Destination, SaveTripInput, Trip } from "../types"; | |
| 8 | + | |
| 9 | +function dateAfter(days: number) { | |
| 10 | + const date = new Date(); | |
| 11 | + date.setDate(date.getDate() + days); | |
| 12 | + const offset = date.getTimezoneOffset(); | |
| 13 | + return new Date(date.getTime() - offset * 60_000).toISOString().slice(0, 10); | |
| 14 | +} | |
| 15 | + | |
| 16 | +export function TripFormDialog({ trip, onClose }: { trip?: Trip; onClose: () => void }) { | |
| 17 | + const dialogRef = useModalDialog(onClose); | |
| 18 | + const queryClient = useQueryClient(); | |
| 19 | + const [destinationCode, setDestinationCode] = useState(trip?.destination.code ?? ""); | |
| 20 | + const [city, setCity] = useState(trip?.city ?? ""); | |
| 21 | + const [startsOn, setStartsOn] = useState(trip?.startsOn ?? dateAfter(14)); | |
| 22 | + const [endsOn, setEndsOn] = useState(trip?.endsOn ?? dateAfter(20)); | |
| 23 | + const [error, setError] = useState(""); | |
| 24 | + const destinations = useQuery({ | |
| 25 | + queryKey: ["destinations"], | |
| 26 | + queryFn: () => api<Destination[]>("/api/v1/catalog/destinations") | |
| 27 | + }); | |
| 28 | + | |
| 29 | + const selectedDestinationCode = destinationCode || destinations.data?.[0]?.code || ""; | |
| 30 | + | |
| 31 | + const save = useMutation({ | |
| 32 | + mutationFn: () => { | |
| 33 | + const body: SaveTripInput = { destinationCode: selectedDestinationCode, city: city.trim(), startsOn, endsOn }; | |
| 34 | + return api<Trip>(trip ? `/api/v1/trips/${trip.id}` : "/api/v1/trips", { | |
| 35 | + method: trip ? "PUT" : "POST", | |
| 36 | + ...jsonBody(body) | |
| 37 | + }); | |
| 38 | + }, | |
| 39 | + onSuccess: async () => { | |
| 40 | + await queryClient.invalidateQueries(); | |
| 41 | + onClose(); | |
| 42 | + }, | |
| 43 | + onError: caught => setError(caught instanceof ApiError ? caught.message : "The trip could not be saved.") | |
| 44 | + }); | |
| 45 | + | |
| 46 | + function submit(event: FormEvent) { | |
| 47 | + event.preventDefault(); | |
| 48 | + setError(""); | |
| 49 | + save.mutate(); | |
| 50 | + } | |
| 51 | + | |
| 52 | + return ( | |
| 53 | + <div className="dialog-backdrop" role="presentation" onMouseDown={event => { | |
| 54 | + if (event.currentTarget === event.target) onClose(); | |
| 55 | + }}> | |
| 56 | + <section ref={dialogRef} className="small-dialog" role="dialog" aria-modal="true" aria-labelledby="trip-dialog-title"> | |
| 57 | + <header className="dialog-header"> | |
| 58 | + <div> | |
| 59 | + <span className="eyebrow">Food mission</span> | |
| 60 | + <h2 id="trip-dialog-title">{trip ? "Edit trip" : "Where are you going?"}</h2> | |
| 61 | + </div> | |
| 62 | + <button className="icon-button" type="button" onClick={onClose} aria-label="Close"><X size={20} /></button> | |
| 63 | + </header> | |
| 64 | + <form className="dialog-form" onSubmit={submit}> | |
| 65 | + <label> | |
| 66 | + <span>Destination</span> | |
| 67 | + <select data-initial-focus={!trip ? "true" : undefined} value={selectedDestinationCode} onChange={event => setDestinationCode(event.target.value)} disabled={Boolean(trip)} required> | |
| 68 | + {destinations.data?.map(item => <option key={item.code} value={item.code}>{item.name}</option>)} | |
| 69 | + </select> | |
| 70 | + {trip && <small>A new destination gets a new mission.</small>} | |
| 71 | + </label> | |
| 72 | + <label> | |
| 73 | + <span>City or route name</span> | |
| 74 | + <input data-initial-focus={trip ? "true" : undefined} value={city} onChange={event => setCity(event.target.value)} placeholder="Osaka" maxLength={100} required /> | |
| 75 | + </label> | |
| 76 | + <div className="form-grid form-grid-two"> | |
| 77 | + <label> | |
| 78 | + <span>Starts</span> | |
| 79 | + <input type="date" value={startsOn} onChange={event => setStartsOn(event.target.value)} required /> | |
| 80 | + </label> | |
| 81 | + <label> | |
| 82 | + <span>Ends</span> | |
| 83 | + <input type="date" value={endsOn} min={startsOn || todayInput()} onChange={event => setEndsOn(event.target.value)} required /> | |
| 84 | + </label> | |
| 85 | + </div> | |
| 86 | + {error && <p className="form-error" role="alert">{error}</p>} | |
| 87 | + <footer className="dialog-actions"> | |
| 88 | + <button className="button button-ghost" type="button" onClick={onClose}>Cancel</button> | |
| 89 | + <button className="button button-primary" type="submit" disabled={save.isPending}> | |
| 90 | + {save.isPending && <LoaderCircle className="spin" size={17} />} | |
| 91 | + {trip ? "Save trip" : "Create mission"} | |
| 92 | + </button> | |
| 93 | + </footer> | |
| 94 | + </form> | |
| 95 | + </section> | |
| 96 | + </div> | |
| 97 | + ); | |
| 98 | +} |
added frontend/src/components/Ui.tsx +63 −0
| @@ -0,0 +1,63 @@ | ||
| 1 | +import type { ReactNode } from "react"; | |
| 2 | +import { AlertCircle, Inbox, LoaderCircle } from "lucide-react"; | |
| 3 | + | |
| 4 | +export function LoadingScreen({ label = "Loading your Tasteprint" }: { label?: string }) { | |
| 5 | + return ( | |
| 6 | + <div className="state-panel" role="status"> | |
| 7 | + <LoaderCircle className="spin" size={24} /> | |
| 8 | + <p>{label}</p> | |
| 9 | + </div> | |
| 10 | + ); | |
| 11 | +} | |
| 12 | + | |
| 13 | +export function ErrorState({ message, action }: { message: string; action?: ReactNode }) { | |
| 14 | + return ( | |
| 15 | + <div className="state-panel state-error" role="alert"> | |
| 16 | + <AlertCircle size={24} /> | |
| 17 | + <p>{message}</p> | |
| 18 | + {action} | |
| 19 | + </div> | |
| 20 | + ); | |
| 21 | +} | |
| 22 | + | |
| 23 | +export function EmptyState({ title, copy, action }: { title: string; copy: string; action?: ReactNode }) { | |
| 24 | + return ( | |
| 25 | + <div className="empty-state"> | |
| 26 | + <Inbox size={28} aria-hidden="true" /> | |
| 27 | + <h3>{title}</h3> | |
| 28 | + <p>{copy}</p> | |
| 29 | + {action} | |
| 30 | + </div> | |
| 31 | + ); | |
| 32 | +} | |
| 33 | + | |
| 34 | +export function PageHeader({ eyebrow, title, copy, action }: { | |
| 35 | + eyebrow: string; | |
| 36 | + title: string; | |
| 37 | + copy?: string; | |
| 38 | + action?: ReactNode; | |
| 39 | +}) { | |
| 40 | + return ( | |
| 41 | + <header className="page-header"> | |
| 42 | + <div> | |
| 43 | + <span className="eyebrow">{eyebrow}</span> | |
| 44 | + <h1>{title}</h1> | |
| 45 | + {copy && <p>{copy}</p>} | |
| 46 | + </div> | |
| 47 | + {action && <div className="page-header-action">{action}</div>} | |
| 48 | + </header> | |
| 49 | + ); | |
| 50 | +} | |
| 51 | + | |
| 52 | +export function StatusPill({ status }: { status: string }) { | |
| 53 | + return <span className={`status-pill status-${status.toLowerCase()}`}>{status.toLowerCase()}</span>; | |
| 54 | +} | |
| 55 | + | |
| 56 | +export function Avatar({ name, url, size = "medium" }: { name: string; url?: string | null; size?: "small" | "medium" | "large" }) { | |
| 57 | + const letters = name.split(/\s+/).slice(0, 2).map(part => part[0]?.toUpperCase()).join(""); | |
| 58 | + return ( | |
| 59 | + <span className={`avatar avatar-${size}`} aria-label={name}> | |
| 60 | + {url ? <img src={url} alt="" /> : letters} | |
| 61 | + </span> | |
| 62 | + ); | |
| 63 | +} |
added frontend/src/components/WorldMap.tsx +95 −0
| @@ -0,0 +1,95 @@ | ||
| 1 | +import { useMemo } from "react"; | |
| 2 | +import { useNavigate } from "react-router-dom"; | |
| 3 | +import { geoNaturalEarth1, geoPath } from "d3-geo"; | |
| 4 | +import { feature } from "topojson-client"; | |
| 5 | +import type { FeatureCollection, Geometry } from "geojson"; | |
| 6 | +import world from "world-atlas/countries-110m.json"; | |
| 7 | +import type { DestinationProgress } from "../types"; | |
| 8 | + | |
| 9 | +const COUNTRY_NUMERIC: Record<string, string> = { | |
| 10 | + EE: "233", | |
| 11 | + GE: "268", | |
| 12 | + IN: "356", | |
| 13 | + IT: "380", | |
| 14 | + JP: "392", | |
| 15 | + KR: "410", | |
| 16 | + MX: "484", | |
| 17 | + MA: "504", | |
| 18 | + PE: "604", | |
| 19 | + PT: "620", | |
| 20 | + TH: "764", | |
| 21 | + VN: "704" | |
| 22 | +}; | |
| 23 | + | |
| 24 | +const countries = feature( | |
| 25 | + world as never, | |
| 26 | + world.objects.countries as never | |
| 27 | +) as unknown as FeatureCollection<Geometry>; | |
| 28 | + | |
| 29 | +const projection = geoNaturalEarth1().fitExtent([[10, 12], [950, 478]], countries); | |
| 30 | +const path = geoPath(projection); | |
| 31 | + | |
| 32 | +interface WorldMapProps { | |
| 33 | + destinations: DestinationProgress[]; | |
| 34 | + interactive?: boolean; | |
| 35 | + className?: string; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export function WorldMap({ destinations, interactive = true, className = "" }: WorldMapProps) { | |
| 39 | + const navigate = useNavigate(); | |
| 40 | + const byNumericCode = useMemo(() => new Map( | |
| 41 | + destinations.map(item => [COUNTRY_NUMERIC[item.destination.code], item]) | |
| 42 | + ), [destinations]); | |
| 43 | + | |
| 44 | + return ( | |
| 45 | + <div className={`world-map ${className}`}> | |
| 46 | + <svg viewBox="0 0 960 490" role="img" aria-label="Your world food coverage map"> | |
| 47 | + <g> | |
| 48 | + {countries.features.map((country, index) => { | |
| 49 | + const item = byNumericCode.get(String(country.id)); | |
| 50 | + const d = path(country); | |
| 51 | + if (!d) return null; | |
| 52 | + const active = Boolean(item); | |
| 53 | + const opacity = item ? Math.max(0.3, item.coverage / 100) : 1; | |
| 54 | + return ( | |
| 55 | + <path | |
| 56 | + key={`${country.id ?? "country"}-${index}`} | |
| 57 | + d={d} | |
| 58 | + className={active ? "map-country map-country-tracked" : "map-country"} | |
| 59 | + fill={item ? item.destination.accentColor : undefined} | |
| 60 | + fillOpacity={item ? opacity : undefined} | |
| 61 | + tabIndex={interactive && active ? 0 : undefined} | |
| 62 | + role={interactive && active ? "link" : undefined} | |
| 63 | + aria-label={item ? `${item.destination.name}, ${item.coverage}% covered` : undefined} | |
| 64 | + onClick={() => interactive && item && navigate(`/app/destinations/${item.destination.code}`)} | |
| 65 | + onKeyDown={event => { | |
| 66 | + if (interactive && item && (event.key === "Enter" || event.key === " ")) { | |
| 67 | + event.preventDefault(); | |
| 68 | + navigate(`/app/destinations/${item.destination.code}`); | |
| 69 | + } | |
| 70 | + }} | |
| 71 | + > | |
| 72 | + {item && <title>{item.destination.name}: {item.coverage}%</title>} | |
| 73 | + </path> | |
| 74 | + ); | |
| 75 | + })} | |
| 76 | + </g> | |
| 77 | + {destinations.filter(item => item.coverage > 0).map(item => { | |
| 78 | + const point = projection([item.destination.centerLng, item.destination.centerLat]); | |
| 79 | + if (!point) return null; | |
| 80 | + return ( | |
| 81 | + <g key={item.destination.code} className="map-pin" transform={`translate(${point[0]} ${point[1]})`}> | |
| 82 | + <circle r="7" /> | |
| 83 | + <circle r="2.4" /> | |
| 84 | + </g> | |
| 85 | + ); | |
| 86 | + })} | |
| 87 | + </svg> | |
| 88 | + <div className="map-legend" aria-hidden="true"> | |
| 89 | + <span><i className="legend-untried" /> Not yet</span> | |
| 90 | + <span><i className="legend-started" /> Started</span> | |
| 91 | + <span><i className="legend-deep" /> Deep taste</span> | |
| 92 | + </div> | |
| 93 | + </div> | |
| 94 | + ); | |
| 95 | +} |
added frontend/src/index.css +2332 −0
| @@ -0,0 +1,2332 @@ | ||
| 1 | +:root { | |
| 2 | + color-scheme: light; | |
| 3 | + font-family: "Manrope", sans-serif; | |
| 4 | + color: #14213d; | |
| 5 | + background: #f4f7fb; | |
| 6 | + font-synthesis: none; | |
| 7 | + text-rendering: optimizeLegibility; | |
| 8 | + --ink: #14213d; | |
| 9 | + --muted: #647089; | |
| 10 | + --line: #d9e1ed; | |
| 11 | + --paper: #ffffff; | |
| 12 | + --canvas: #f4f7fb; | |
| 13 | + --blue: #2155f5; | |
| 14 | + --blue-dark: #123ab5; | |
| 15 | + --blue-pale: #e8efff; | |
| 16 | + --mango: #ffc857; | |
| 17 | + --coral: #ff6b5e; | |
| 18 | + --mint: #84dcc6; | |
| 19 | + --shadow: 0 18px 55px rgba(20, 33, 61, 0.11); | |
| 20 | + --radius: 18px; | |
| 21 | +} | |
| 22 | + | |
| 23 | +* { | |
| 24 | + box-sizing: border-box; | |
| 25 | +} | |
| 26 | + | |
| 27 | +html { | |
| 28 | + min-width: 320px; | |
| 29 | + scroll-behavior: smooth; | |
| 30 | +} | |
| 31 | + | |
| 32 | +body { | |
| 33 | + margin: 0; | |
| 34 | + min-width: 320px; | |
| 35 | + min-height: 100vh; | |
| 36 | + background: var(--canvas); | |
| 37 | +} | |
| 38 | + | |
| 39 | +body, button, input, select, textarea { | |
| 40 | + font-family: "Manrope", sans-serif; | |
| 41 | +} | |
| 42 | + | |
| 43 | +button, input, select, textarea { | |
| 44 | + font-size: inherit; | |
| 45 | +} | |
| 46 | + | |
| 47 | +button, a { | |
| 48 | + -webkit-tap-highlight-color: transparent; | |
| 49 | +} | |
| 50 | + | |
| 51 | +button { | |
| 52 | + cursor: pointer; | |
| 53 | +} | |
| 54 | + | |
| 55 | +button:disabled { | |
| 56 | + cursor: not-allowed; | |
| 57 | + opacity: 0.62; | |
| 58 | +} | |
| 59 | + | |
| 60 | +a { | |
| 61 | + color: inherit; | |
| 62 | + text-decoration: none; | |
| 63 | +} | |
| 64 | + | |
| 65 | +img { | |
| 66 | + display: block; | |
| 67 | + max-width: 100%; | |
| 68 | +} | |
| 69 | + | |
| 70 | +h1, h2, h3, p { | |
| 71 | + margin-top: 0; | |
| 72 | +} | |
| 73 | + | |
| 74 | +h1, h2, h3 { | |
| 75 | + color: var(--ink); | |
| 76 | + font-family: "Barlow Condensed", sans-serif; | |
| 77 | + line-height: 0.98; | |
| 78 | +} | |
| 79 | + | |
| 80 | +h1 { | |
| 81 | + font-size: clamp(2.5rem, 6vw, 5.4rem); | |
| 82 | + letter-spacing: -0.035em; | |
| 83 | +} | |
| 84 | + | |
| 85 | +h2 { | |
| 86 | + font-size: clamp(1.55rem, 3vw, 2.15rem); | |
| 87 | + letter-spacing: -0.015em; | |
| 88 | +} | |
| 89 | + | |
| 90 | +h3 { | |
| 91 | + font-size: 1.35rem; | |
| 92 | +} | |
| 93 | + | |
| 94 | +:focus-visible { | |
| 95 | + outline: 3px solid var(--mango); | |
| 96 | + outline-offset: 3px; | |
| 97 | +} | |
| 98 | + | |
| 99 | +.sr-only { | |
| 100 | + position: absolute; | |
| 101 | + width: 1px; | |
| 102 | + height: 1px; | |
| 103 | + padding: 0; | |
| 104 | + margin: -1px; | |
| 105 | + overflow: hidden; | |
| 106 | + clip: rect(0, 0, 0, 0); | |
| 107 | + white-space: nowrap; | |
| 108 | + border: 0; | |
| 109 | +} | |
| 110 | + | |
| 111 | +.skip-link { | |
| 112 | + position: fixed; | |
| 113 | + z-index: 200; | |
| 114 | + top: 10px; | |
| 115 | + left: 10px; | |
| 116 | + padding: 10px 14px; | |
| 117 | + border-radius: 8px; | |
| 118 | + background: var(--ink); | |
| 119 | + color: white; | |
| 120 | + transform: translateY(-150%); | |
| 121 | +} | |
| 122 | + | |
| 123 | +.skip-link:focus { | |
| 124 | + transform: translateY(0); | |
| 125 | +} | |
| 126 | + | |
| 127 | +.eyebrow { | |
| 128 | + display: block; | |
| 129 | + margin-bottom: 8px; | |
| 130 | + color: var(--blue); | |
| 131 | + font-family: "IBM Plex Mono", monospace; | |
| 132 | + font-size: 0.68rem; | |
| 133 | + font-weight: 500; | |
| 134 | + letter-spacing: 0.12em; | |
| 135 | + line-height: 1.4; | |
| 136 | + text-transform: uppercase; | |
| 137 | +} | |
| 138 | + | |
| 139 | +.eyebrow-light { | |
| 140 | + color: #dce5ff; | |
| 141 | +} | |
| 142 | + | |
| 143 | +.button { | |
| 144 | + display: inline-flex; | |
| 145 | + min-height: 42px; | |
| 146 | + align-items: center; | |
| 147 | + justify-content: center; | |
| 148 | + gap: 8px; | |
| 149 | + padding: 10px 17px; | |
| 150 | + border: 1px solid transparent; | |
| 151 | + border-radius: 10px; | |
| 152 | + font-weight: 700; | |
| 153 | + line-height: 1; | |
| 154 | + transition: transform 140ms ease, box-shadow 140ms ease, background 140ms ease; | |
| 155 | +} | |
| 156 | + | |
| 157 | +.button:hover:not(:disabled) { | |
| 158 | + transform: translateY(-1px); | |
| 159 | +} | |
| 160 | + | |
| 161 | +.button-primary { | |
| 162 | + background: var(--blue); | |
| 163 | + color: white; | |
| 164 | + box-shadow: 0 8px 20px rgba(33, 85, 245, 0.22); | |
| 165 | +} | |
| 166 | + | |
| 167 | +.button-primary:hover:not(:disabled) { | |
| 168 | + background: var(--blue-dark); | |
| 169 | +} | |
| 170 | + | |
| 171 | +.button-outline { | |
| 172 | + border-color: #b9c5d8; | |
| 173 | + background: white; | |
| 174 | + color: var(--ink); | |
| 175 | +} | |
| 176 | + | |
| 177 | +.button-outline:hover:not(:disabled) { | |
| 178 | + border-color: var(--blue); | |
| 179 | + color: var(--blue); | |
| 180 | +} | |
| 181 | + | |
| 182 | +.button-ghost { | |
| 183 | + background: transparent; | |
| 184 | + color: var(--ink); | |
| 185 | +} | |
| 186 | + | |
| 187 | +.button-ink { | |
| 188 | + background: var(--ink); | |
| 189 | + color: white; | |
| 190 | +} | |
| 191 | + | |
| 192 | +.button-danger { | |
| 193 | + background: #b9232e; | |
| 194 | + color: white; | |
| 195 | +} | |
| 196 | + | |
| 197 | +.button-paper, .button-light { | |
| 198 | + background: white; | |
| 199 | + color: var(--ink); | |
| 200 | + box-shadow: 0 8px 20px rgba(20, 33, 61, 0.14); | |
| 201 | +} | |
| 202 | + | |
| 203 | +.button-large { | |
| 204 | + min-height: 50px; | |
| 205 | + padding: 14px 21px; | |
| 206 | +} | |
| 207 | + | |
| 208 | +.button-full { | |
| 209 | + width: 100%; | |
| 210 | +} | |
| 211 | + | |
| 212 | +.text-link, .small-link, .panel-link, .back-link { | |
| 213 | + display: inline-flex; | |
| 214 | + align-items: center; | |
| 215 | + gap: 6px; | |
| 216 | + color: var(--blue); | |
| 217 | + font-weight: 700; | |
| 218 | +} | |
| 219 | + | |
| 220 | +.small-link { | |
| 221 | + font-size: 0.82rem; | |
| 222 | +} | |
| 223 | + | |
| 224 | +.back-link { | |
| 225 | + margin-bottom: 24px; | |
| 226 | + font-size: 0.86rem; | |
| 227 | +} | |
| 228 | + | |
| 229 | +.icon-button { | |
| 230 | + display: inline-grid; | |
| 231 | + width: 40px; | |
| 232 | + height: 40px; | |
| 233 | + flex: 0 0 40px; | |
| 234 | + place-items: center; | |
| 235 | + padding: 0; | |
| 236 | + border: 1px solid var(--line); | |
| 237 | + border-radius: 10px; | |
| 238 | + background: white; | |
| 239 | + color: var(--ink); | |
| 240 | +} | |
| 241 | + | |
| 242 | +.icon-button:hover { | |
| 243 | + border-color: var(--blue); | |
| 244 | + color: var(--blue); | |
| 245 | +} | |
| 246 | + | |
| 247 | +.icon-button-primary { | |
| 248 | + border-color: var(--blue); | |
| 249 | + background: var(--blue); | |
| 250 | + color: white; | |
| 251 | +} | |
| 252 | + | |
| 253 | +.icon-button-danger, .danger-link { | |
| 254 | + color: #b9232e; | |
| 255 | +} | |
| 256 | + | |
| 257 | +.brand { | |
| 258 | + display: inline-flex; | |
| 259 | + align-items: center; | |
| 260 | + gap: 9px; | |
| 261 | + width: max-content; | |
| 262 | +} | |
| 263 | + | |
| 264 | +.brand-mark { | |
| 265 | + display: grid; | |
| 266 | + width: 38px; | |
| 267 | + height: 38px; | |
| 268 | + place-items: center; | |
| 269 | + border: 2px solid currentColor; | |
| 270 | + border-radius: 50% 48% 51% 46%; | |
| 271 | + color: var(--blue); | |
| 272 | + transform: rotate(-6deg); | |
| 273 | +} | |
| 274 | + | |
| 275 | +.brand-mark span { | |
| 276 | + font-family: "IBM Plex Mono", monospace; | |
| 277 | + font-size: 0.72rem; | |
| 278 | + font-weight: 700; | |
| 279 | + transform: rotate(6deg); | |
| 280 | +} | |
| 281 | + | |
| 282 | +.brand-word { | |
| 283 | + display: flex; | |
| 284 | + font-family: "Barlow Condensed", sans-serif; | |
| 285 | + font-size: 1.42rem; | |
| 286 | + letter-spacing: -0.03em; | |
| 287 | +} | |
| 288 | + | |
| 289 | +.brand-word strong:last-child { | |
| 290 | + color: var(--blue); | |
| 291 | +} | |
| 292 | + | |
| 293 | +.panel { | |
| 294 | + padding: 25px; | |
| 295 | + border: 1px solid var(--line); | |
| 296 | + border-radius: var(--radius); | |
| 297 | + background: var(--paper); | |
| 298 | +} | |
| 299 | + | |
| 300 | +.page { | |
| 301 | + width: min(1180px, 100%); | |
| 302 | + margin: 0 auto; | |
| 303 | + padding: 48px 44px 72px; | |
| 304 | +} | |
| 305 | + | |
| 306 | +.page-header { | |
| 307 | + display: flex; | |
| 308 | + align-items: flex-end; | |
| 309 | + justify-content: space-between; | |
| 310 | + gap: 28px; | |
| 311 | + margin-bottom: 34px; | |
| 312 | +} | |
| 313 | + | |
| 314 | +.page-header > div:first-child { | |
| 315 | + max-width: 780px; | |
| 316 | +} | |
| 317 | + | |
| 318 | +.page-header h1 { | |
| 319 | + margin-bottom: 12px; | |
| 320 | + font-size: clamp(2.5rem, 5vw, 4.25rem); | |
| 321 | +} | |
| 322 | + | |
| 323 | +.page-header p { | |
| 324 | + max-width: 650px; | |
| 325 | + margin-bottom: 0; | |
| 326 | + color: var(--muted); | |
| 327 | + line-height: 1.7; | |
| 328 | +} | |
| 329 | + | |
| 330 | +.page-header-action { | |
| 331 | + flex: 0 0 auto; | |
| 332 | +} | |
| 333 | + | |
| 334 | +.section-heading { | |
| 335 | + display: flex; | |
| 336 | + align-items: flex-end; | |
| 337 | + justify-content: space-between; | |
| 338 | + gap: 18px; | |
| 339 | + margin-bottom: 20px; | |
| 340 | +} | |
| 341 | + | |
| 342 | +.section-heading h2 { | |
| 343 | + margin-bottom: 0; | |
| 344 | +} | |
| 345 | + | |
| 346 | +.section-note { | |
| 347 | + display: inline-flex; | |
| 348 | + align-items: center; | |
| 349 | + gap: 6px; | |
| 350 | + color: var(--muted); | |
| 351 | + font-size: 0.78rem; | |
| 352 | +} | |
| 353 | + | |
| 354 | +.state-panel, .empty-state { | |
| 355 | + display: flex; | |
| 356 | + min-height: 260px; | |
| 357 | + flex-direction: column; | |
| 358 | + align-items: center; | |
| 359 | + justify-content: center; | |
| 360 | + gap: 10px; | |
| 361 | + padding: 32px; | |
| 362 | + text-align: center; | |
| 363 | +} | |
| 364 | + | |
| 365 | +.state-panel p, .empty-state p { | |
| 366 | + max-width: 430px; | |
| 367 | + margin: 0; | |
| 368 | + color: var(--muted); | |
| 369 | +} | |
| 370 | + | |
| 371 | +.state-error { | |
| 372 | + color: #b9232e; | |
| 373 | +} | |
| 374 | + | |
| 375 | +.empty-state { | |
| 376 | + border: 1px dashed #b7c3d5; | |
| 377 | + border-radius: var(--radius); | |
| 378 | + background: rgba(255, 255, 255, 0.55); | |
| 379 | +} | |
| 380 | + | |
| 381 | +.empty-state h3 { | |
| 382 | + margin: 4px 0 0; | |
| 383 | + font-size: 1.7rem; | |
| 384 | +} | |
| 385 | + | |
| 386 | +.spin { | |
| 387 | + animation: spin 900ms linear infinite; | |
| 388 | +} | |
| 389 | + | |
| 390 | +@keyframes spin { | |
| 391 | + to { transform: rotate(360deg); } | |
| 392 | +} | |
| 393 | + | |
| 394 | +.status-pill { | |
| 395 | + display: inline-block; | |
| 396 | + padding: 5px 9px; | |
| 397 | + border-radius: 999px; | |
| 398 | + background: #edf1f6; | |
| 399 | + color: #536078; | |
| 400 | + font-family: "IBM Plex Mono", monospace; | |
| 401 | + font-size: 0.63rem; | |
| 402 | + letter-spacing: 0.08em; | |
| 403 | + text-transform: uppercase; | |
| 404 | +} | |
| 405 | + | |
| 406 | +.status-active { | |
| 407 | + background: #d8f6ed; | |
| 408 | + color: #12644f; | |
| 409 | +} | |
| 410 | + | |
| 411 | +.status-upcoming { | |
| 412 | + background: var(--blue-pale); | |
| 413 | + color: var(--blue-dark); | |
| 414 | +} | |
| 415 | + | |
| 416 | +.avatar { | |
| 417 | + display: inline-grid; | |
| 418 | + flex: 0 0 auto; | |
| 419 | + place-items: center; | |
| 420 | + overflow: hidden; | |
| 421 | + border-radius: 50%; | |
| 422 | + background: var(--mango); | |
| 423 | + color: var(--ink); | |
| 424 | + font-family: "Barlow Condensed", sans-serif; | |
| 425 | + font-weight: 700; | |
| 426 | +} | |
| 427 | + | |
| 428 | +.avatar img { | |
| 429 | + width: 100%; | |
| 430 | + height: 100%; | |
| 431 | + object-fit: cover; | |
| 432 | +} | |
| 433 | + | |
| 434 | +.avatar-small { width: 34px; height: 34px; font-size: 0.8rem; } | |
| 435 | +.avatar-medium { width: 48px; height: 48px; font-size: 1rem; } | |
| 436 | +.avatar-large { width: 72px; height: 72px; font-size: 1.35rem; } | |
| 437 | + | |
| 438 | +/* Landing */ | |
| 439 | +.landing-page { | |
| 440 | + min-height: 100vh; | |
| 441 | + overflow: hidden; | |
| 442 | + background: | |
| 443 | + radial-gradient(circle at 75% 15%, rgba(132, 220, 198, 0.28), transparent 23rem), | |
| 444 | + linear-gradient(165deg, #f9fbff 0%, #edf3ff 62%, #fff7e7 100%); | |
| 445 | +} | |
| 446 | + | |
| 447 | +.landing-nav, .public-nav { | |
| 448 | + display: flex; | |
| 449 | + width: min(1240px, calc(100% - 48px)); | |
| 450 | + align-items: center; | |
| 451 | + justify-content: space-between; | |
| 452 | + margin: 0 auto; | |
| 453 | + padding: 24px 0; | |
| 454 | +} | |
| 455 | + | |
| 456 | +.landing-nav > div { | |
| 457 | + display: flex; | |
| 458 | + gap: 8px; | |
| 459 | +} | |
| 460 | + | |
| 461 | +.landing-hero { | |
| 462 | + display: grid; | |
| 463 | + width: min(1240px, calc(100% - 48px)); | |
| 464 | + min-height: 650px; | |
| 465 | + grid-template-columns: 0.9fr 1.1fr; | |
| 466 | + align-items: center; | |
| 467 | + gap: 60px; | |
| 468 | + margin: 0 auto; | |
| 469 | + padding: 58px 0 90px; | |
| 470 | +} | |
| 471 | + | |
| 472 | +.hero-copy h1 { | |
| 473 | + max-width: 650px; | |
| 474 | + margin-bottom: 24px; | |
| 475 | +} | |
| 476 | + | |
| 477 | +.hero-copy h1 em { | |
| 478 | + color: var(--blue); | |
| 479 | + font-style: normal; | |
| 480 | +} | |
| 481 | + | |
| 482 | +.hero-copy > p { | |
| 483 | + max-width: 610px; | |
| 484 | + margin-bottom: 30px; | |
| 485 | + color: #4e5b73; | |
| 486 | + font-size: 1.05rem; | |
| 487 | + line-height: 1.75; | |
| 488 | +} | |
| 489 | + | |
| 490 | +.hero-actions { | |
| 491 | + display: flex; | |
| 492 | + align-items: center; | |
| 493 | + gap: 22px; | |
| 494 | +} | |
| 495 | + | |
| 496 | +.hero-ticket { | |
| 497 | + position: relative; | |
| 498 | + min-height: 500px; | |
| 499 | + padding: 28px 30px; | |
| 500 | + overflow: hidden; | |
| 501 | + border: 1px solid rgba(255, 255, 255, 0.35); | |
| 502 | + border-radius: 26px; | |
| 503 | + background: var(--blue); | |
| 504 | + color: white; | |
| 505 | + box-shadow: 0 35px 80px rgba(25, 63, 170, 0.28); | |
| 506 | + transform: rotate(2deg); | |
| 507 | +} | |
| 508 | + | |
| 509 | +.hero-ticket::before, .tasteprint-ticket::before, .public-passport::before { | |
| 510 | + position: absolute; | |
| 511 | + inset: 12px; | |
| 512 | + border: 1px dashed rgba(255, 255, 255, 0.36); | |
| 513 | + border-radius: 18px; | |
| 514 | + content: ""; | |
| 515 | + pointer-events: none; | |
| 516 | +} | |
| 517 | + | |
| 518 | +.ticket-route, .trip-card-route { | |
| 519 | + display: flex; | |
| 520 | + align-items: center; | |
| 521 | + gap: 10px; | |
| 522 | + font-family: "IBM Plex Mono", monospace; | |
| 523 | + font-size: 0.78rem; | |
| 524 | + letter-spacing: 0.1em; | |
| 525 | +} | |
| 526 | + | |
| 527 | +.ticket-route i, .trip-card-route i { | |
| 528 | + height: 1px; | |
| 529 | + flex: 1; | |
| 530 | + border-top: 1px dashed currentColor; | |
| 531 | + opacity: 0.55; | |
| 532 | +} | |
| 533 | + | |
| 534 | +.ticket-score { | |
| 535 | + display: flex; | |
| 536 | + width: max-content; | |
| 537 | + flex-direction: column; | |
| 538 | + margin-top: 42px; | |
| 539 | +} | |
| 540 | + | |
| 541 | +.ticket-score span, .ticket-score small { | |
| 542 | + color: #dce5ff; | |
| 543 | +} | |
| 544 | + | |
| 545 | +.ticket-score strong { | |
| 546 | + font-family: "Barlow Condensed", sans-serif; | |
| 547 | + font-size: 5.4rem; | |
| 548 | + line-height: 0.92; | |
| 549 | +} | |
| 550 | + | |
| 551 | +.hero-world-map { | |
| 552 | + margin: -65px -20px -35px 80px; | |
| 553 | +} | |
| 554 | + | |
| 555 | +.ticket-stamp { | |
| 556 | + position: absolute; | |
| 557 | + right: 42px; | |
| 558 | + bottom: 34px; | |
| 559 | + display: grid; | |
| 560 | + width: 78px; | |
| 561 | + height: 78px; | |
| 562 | + place-items: center; | |
| 563 | + border: 3px double var(--mango); | |
| 564 | + border-radius: 50%; | |
| 565 | + color: var(--mango); | |
| 566 | + font-family: "IBM Plex Mono", monospace; | |
| 567 | + font-size: 0.7rem; | |
| 568 | + line-height: 1.3; | |
| 569 | + text-align: center; | |
| 570 | + transform: rotate(-10deg); | |
| 571 | +} | |
| 572 | + | |
| 573 | +.landing-proof { | |
| 574 | + display: grid; | |
| 575 | + width: min(1240px, calc(100% - 48px)); | |
| 576 | + grid-template-columns: repeat(3, 1fr); | |
| 577 | + margin: 0 auto 80px; | |
| 578 | + border: 1px solid var(--line); | |
| 579 | + border-radius: 20px; | |
| 580 | + background: rgba(255, 255, 255, 0.82); | |
| 581 | + box-shadow: var(--shadow); | |
| 582 | +} | |
| 583 | + | |
| 584 | +.landing-proof article { | |
| 585 | + padding: 30px; | |
| 586 | +} | |
| 587 | + | |
| 588 | +.landing-proof article + article { | |
| 589 | + border-left: 1px dashed #c5cede; | |
| 590 | +} | |
| 591 | + | |
| 592 | +.landing-proof article > span { | |
| 593 | + display: block; | |
| 594 | + margin: 14px 0 8px; | |
| 595 | + color: var(--blue); | |
| 596 | + font-family: "IBM Plex Mono", monospace; | |
| 597 | + font-size: 0.65rem; | |
| 598 | + letter-spacing: 0.08em; | |
| 599 | + text-transform: uppercase; | |
| 600 | +} | |
| 601 | + | |
| 602 | +.landing-proof h2 { | |
| 603 | + margin-bottom: 10px; | |
| 604 | + font-size: 1.75rem; | |
| 605 | +} | |
| 606 | + | |
| 607 | +.landing-proof p { | |
| 608 | + margin: 0; | |
| 609 | + color: var(--muted); | |
| 610 | + font-size: 0.9rem; | |
| 611 | + line-height: 1.65; | |
| 612 | +} | |
| 613 | + | |
| 614 | +/* Authentication */ | |
| 615 | +.auth-page { | |
| 616 | + display: grid; | |
| 617 | + min-height: 100vh; | |
| 618 | + grid-template-columns: minmax(380px, 0.9fr) minmax(500px, 1.1fr); | |
| 619 | +} | |
| 620 | + | |
| 621 | +.auth-story { | |
| 622 | + display: flex; | |
| 623 | + flex-direction: column; | |
| 624 | + justify-content: space-between; | |
| 625 | + padding: 44px 9vw 44px 54px; | |
| 626 | + overflow: hidden; | |
| 627 | + background: var(--blue); | |
| 628 | + color: white; | |
| 629 | +} | |
| 630 | + | |
| 631 | +.auth-story .brand, .auth-story h1 { | |
| 632 | + color: white; | |
| 633 | +} | |
| 634 | + | |
| 635 | +.auth-story .brand-mark, .auth-story .brand-word strong:last-child { | |
| 636 | + color: var(--mango); | |
| 637 | +} | |
| 638 | + | |
| 639 | +.auth-story h1 { | |
| 640 | + margin: 0; | |
| 641 | + font-size: clamp(3rem, 5.5vw, 5.8rem); | |
| 642 | +} | |
| 643 | + | |
| 644 | +.auth-story > p { | |
| 645 | + margin: 0; | |
| 646 | + color: #dce5ff; | |
| 647 | + font-size: 0.86rem; | |
| 648 | +} | |
| 649 | + | |
| 650 | +.auth-stamps { | |
| 651 | + position: relative; | |
| 652 | + height: 160px; | |
| 653 | + margin-top: 25px; | |
| 654 | +} | |
| 655 | + | |
| 656 | +.auth-stamp { | |
| 657 | + position: absolute; | |
| 658 | + display: grid; | |
| 659 | + width: 105px; | |
| 660 | + height: 105px; | |
| 661 | + place-items: center; | |
| 662 | + border: 3px double currentColor; | |
| 663 | + border-radius: 50%; | |
| 664 | + font-family: "Barlow Condensed", sans-serif; | |
| 665 | + font-size: 2.2rem; | |
| 666 | + line-height: 0.8; | |
| 667 | + text-align: center; | |
| 668 | +} | |
| 669 | + | |
| 670 | +.auth-stamp small { | |
| 671 | + display: block; | |
| 672 | + font-family: "IBM Plex Mono", monospace; | |
| 673 | + font-size: 0.6rem; | |
| 674 | +} | |
| 675 | + | |
| 676 | +.auth-stamp-jp { left: 0; color: var(--mango); transform: rotate(-12deg); } | |
| 677 | +.auth-stamp-pt { left: 90px; top: 30px; color: var(--mint); transform: rotate(8deg); } | |
| 678 | +.auth-stamp-mx { left: 190px; top: -8px; color: #ffc3be; transform: rotate(-3deg); } | |
| 679 | + | |
| 680 | +.auth-panel { | |
| 681 | + display: flex; | |
| 682 | + flex-direction: column; | |
| 683 | + padding: 38px 54px; | |
| 684 | + background: white; | |
| 685 | +} | |
| 686 | + | |
| 687 | +.auth-form-wrap { | |
| 688 | + width: min(430px, 100%); | |
| 689 | + margin: auto; | |
| 690 | +} | |
| 691 | + | |
| 692 | +.auth-seal { | |
| 693 | + display: grid; | |
| 694 | + width: 54px; | |
| 695 | + height: 54px; | |
| 696 | + place-items: center; | |
| 697 | + margin-bottom: 24px; | |
| 698 | + border-radius: 50%; | |
| 699 | + background: var(--blue-pale); | |
| 700 | + color: var(--blue); | |
| 701 | +} | |
| 702 | + | |
| 703 | +.auth-form-wrap h2 { | |
| 704 | + margin-bottom: 28px; | |
| 705 | + font-size: 2.5rem; | |
| 706 | +} | |
| 707 | + | |
| 708 | +.auth-form, .dialog-form, .profile-form { | |
| 709 | + display: flex; | |
| 710 | + flex-direction: column; | |
| 711 | + gap: 18px; | |
| 712 | +} | |
| 713 | + | |
| 714 | +.auth-form-wrap > .button-outline { | |
| 715 | + margin-top: 12px; | |
| 716 | +} | |
| 717 | + | |
| 718 | +.auth-switch { | |
| 719 | + margin: 24px 0 0; | |
| 720 | + color: var(--muted); | |
| 721 | + font-size: 0.88rem; | |
| 722 | + text-align: center; | |
| 723 | +} | |
| 724 | + | |
| 725 | +.auth-switch a { | |
| 726 | + color: var(--blue); | |
| 727 | + font-weight: 700; | |
| 728 | +} | |
| 729 | + | |
| 730 | +label > span, fieldset legend { | |
| 731 | + display: block; | |
| 732 | + margin-bottom: 7px; | |
| 733 | + color: #44516a; | |
| 734 | + font-size: 0.78rem; | |
| 735 | + font-weight: 700; | |
| 736 | +} | |
| 737 | + | |
| 738 | +input, select, textarea { | |
| 739 | + width: 100%; | |
| 740 | + border: 1px solid #c8d1df; | |
| 741 | + border-radius: 9px; | |
| 742 | + background: white; | |
| 743 | + color: var(--ink); | |
| 744 | +} | |
| 745 | + | |
| 746 | +input, select { | |
| 747 | + height: 45px; | |
| 748 | + padding: 0 12px; | |
| 749 | +} | |
| 750 | + | |
| 751 | +textarea { | |
| 752 | + padding: 11px 12px; | |
| 753 | + line-height: 1.55; | |
| 754 | + resize: vertical; | |
| 755 | +} | |
| 756 | + | |
| 757 | +input:focus, select:focus, textarea:focus { | |
| 758 | + border-color: var(--blue); | |
| 759 | + outline: 3px solid rgba(33, 85, 245, 0.12); | |
| 760 | +} | |
| 761 | + | |
| 762 | +.form-error { | |
| 763 | + margin: 0; | |
| 764 | + color: #b9232e; | |
| 765 | + font-size: 0.82rem; | |
| 766 | + line-height: 1.5; | |
| 767 | +} | |
| 768 | + | |
| 769 | +.form-hint, label small { | |
| 770 | + display: block; | |
| 771 | + margin: 6px 0 0; | |
| 772 | + color: var(--muted); | |
| 773 | + font-size: 0.74rem; | |
| 774 | + line-height: 1.5; | |
| 775 | +} | |
| 776 | + | |
| 777 | +.form-grid { | |
| 778 | + display: grid; | |
| 779 | + gap: 14px; | |
| 780 | +} | |
| 781 | + | |
| 782 | +.form-grid-two { | |
| 783 | + grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| 784 | +} | |
| 785 | + | |
| 786 | +/* App frame */ | |
| 787 | +.app-frame { | |
| 788 | + min-height: 100vh; | |
| 789 | +} | |
| 790 | + | |
| 791 | +.sidebar { | |
| 792 | + position: fixed; | |
| 793 | + z-index: 30; | |
| 794 | + inset: 0 auto 0 0; | |
| 795 | + display: flex; | |
| 796 | + width: 235px; | |
| 797 | + flex-direction: column; | |
| 798 | + padding: 28px 20px 22px; | |
| 799 | + border-right: 1px solid #233657; | |
| 800 | + background: var(--ink); | |
| 801 | + color: white; | |
| 802 | +} | |
| 803 | + | |
| 804 | +.sidebar .brand { | |
| 805 | + margin-left: 4px; | |
| 806 | + color: white; | |
| 807 | +} | |
| 808 | + | |
| 809 | +.sidebar .brand-mark, .sidebar .brand-word strong:last-child { | |
| 810 | + color: var(--mango); | |
| 811 | +} | |
| 812 | + | |
| 813 | +.quick-stamp-button { | |
| 814 | + margin: 34px 0 25px; | |
| 815 | + background: var(--mango); | |
| 816 | + color: var(--ink); | |
| 817 | +} | |
| 818 | + | |
| 819 | +.side-nav { | |
| 820 | + display: flex; | |
| 821 | + flex-direction: column; | |
| 822 | + gap: 4px; | |
| 823 | +} | |
| 824 | + | |
| 825 | +.side-nav a { | |
| 826 | + display: flex; | |
| 827 | + align-items: center; | |
| 828 | + gap: 12px; | |
| 829 | + padding: 11px 12px; | |
| 830 | + border-radius: 9px; | |
| 831 | + color: #b9c7de; | |
| 832 | + font-size: 0.88rem; | |
| 833 | + font-weight: 600; | |
| 834 | +} | |
| 835 | + | |
| 836 | +.side-nav a:hover, .side-nav a.active { | |
| 837 | + background: #223454; | |
| 838 | + color: white; | |
| 839 | +} | |
| 840 | + | |
| 841 | +.side-nav a.active svg { | |
| 842 | + color: var(--mango); | |
| 843 | +} | |
| 844 | + | |
| 845 | +.sidebar-account { | |
| 846 | + display: flex; | |
| 847 | + align-items: center; | |
| 848 | + gap: 5px; | |
| 849 | + margin-top: auto; | |
| 850 | + padding-top: 18px; | |
| 851 | + border-top: 1px solid #304260; | |
| 852 | +} | |
| 853 | + | |
| 854 | +.account-link { | |
| 855 | + display: flex; | |
| 856 | + min-width: 0; | |
| 857 | + flex: 1; | |
| 858 | + align-items: center; | |
| 859 | + gap: 9px; | |
| 860 | + padding: 4px; | |
| 861 | + border: 0; | |
| 862 | + background: transparent; | |
| 863 | + color: white; | |
| 864 | + text-align: left; | |
| 865 | +} | |
| 866 | + | |
| 867 | +.account-link > span:not(.avatar) { | |
| 868 | + display: flex; | |
| 869 | + min-width: 0; | |
| 870 | + flex: 1; | |
| 871 | + flex-direction: column; | |
| 872 | +} | |
| 873 | + | |
| 874 | +.account-link strong { | |
| 875 | + overflow: hidden; | |
| 876 | + font-size: 0.78rem; | |
| 877 | + text-overflow: ellipsis; | |
| 878 | + white-space: nowrap; | |
| 879 | +} | |
| 880 | + | |
| 881 | +.account-link small { | |
| 882 | + color: #9cabc1; | |
| 883 | + font-size: 0.66rem; | |
| 884 | +} | |
| 885 | + | |
| 886 | +.logout-button { | |
| 887 | + display: grid; | |
| 888 | + width: 34px; | |
| 889 | + height: 34px; | |
| 890 | + place-items: center; | |
| 891 | + border: 0; | |
| 892 | + background: transparent; | |
| 893 | + color: #9cabc1; | |
| 894 | +} | |
| 895 | + | |
| 896 | +.app-main { | |
| 897 | + min-height: 100vh; | |
| 898 | + margin-left: 235px; | |
| 899 | +} | |
| 900 | + | |
| 901 | +.mobile-header, .mobile-nav { | |
| 902 | + display: none; | |
| 903 | +} | |
| 904 | + | |
| 905 | +/* Maps and rings */ | |
| 906 | +.world-map svg { | |
| 907 | + display: block; | |
| 908 | + width: 100%; | |
| 909 | + height: auto; | |
| 910 | +} | |
| 911 | + | |
| 912 | +.map-country { | |
| 913 | + fill: #dce4f0; | |
| 914 | + stroke: rgba(255, 255, 255, 0.85); | |
| 915 | + stroke-width: 0.7; | |
| 916 | + transition: opacity 140ms ease, filter 140ms ease; | |
| 917 | +} | |
| 918 | + | |
| 919 | +.map-country-tracked { | |
| 920 | + cursor: pointer; | |
| 921 | +} | |
| 922 | + | |
| 923 | +.map-country-tracked:hover, .map-country-tracked:focus { | |
| 924 | + filter: brightness(0.9); | |
| 925 | +} | |
| 926 | + | |
| 927 | +.map-pin circle:first-child { | |
| 928 | + fill: white; | |
| 929 | + stroke: var(--ink); | |
| 930 | + stroke-width: 2; | |
| 931 | +} | |
| 932 | + | |
| 933 | +.map-pin circle:last-child { | |
| 934 | + fill: var(--ink); | |
| 935 | +} | |
| 936 | + | |
| 937 | +.map-legend { | |
| 938 | + display: flex; | |
| 939 | + justify-content: center; | |
| 940 | + gap: 18px; | |
| 941 | + color: var(--muted); | |
| 942 | + font-size: 0.65rem; | |
| 943 | +} | |
| 944 | + | |
| 945 | +.map-legend span { | |
| 946 | + display: flex; | |
| 947 | + align-items: center; | |
| 948 | + gap: 5px; | |
| 949 | +} | |
| 950 | + | |
| 951 | +.map-legend i { | |
| 952 | + display: inline-block; | |
| 953 | + width: 9px; | |
| 954 | + height: 9px; | |
| 955 | + border-radius: 2px; | |
| 956 | + background: #dce4f0; | |
| 957 | +} | |
| 958 | + | |
| 959 | +.map-legend .legend-started { background: #93acef; } | |
| 960 | +.map-legend .legend-deep { background: var(--blue); } | |
| 961 | + | |
| 962 | +.hero-ticket .map-country, .tasteprint-ticket .map-country, .public-passport .map-country { | |
| 963 | + fill: rgba(255, 255, 255, 0.18); | |
| 964 | + stroke: rgba(255, 255, 255, 0.35); | |
| 965 | +} | |
| 966 | + | |
| 967 | +.hero-ticket .map-legend, .tasteprint-ticket .map-legend, .public-passport .map-legend { | |
| 968 | + display: none; | |
| 969 | +} | |
| 970 | + | |
| 971 | +.progress-ring { | |
| 972 | + --progress: 0deg; | |
| 973 | + display: grid; | |
| 974 | + flex: 0 0 auto; | |
| 975 | + place-items: center; | |
| 976 | + border-radius: 50%; | |
| 977 | + background: conic-gradient(var(--blue) var(--progress), #e3e8f0 0); | |
| 978 | +} | |
| 979 | + | |
| 980 | +.progress-ring::before { | |
| 981 | + grid-area: 1 / 1; | |
| 982 | + width: 75%; | |
| 983 | + height: 75%; | |
| 984 | + border-radius: 50%; | |
| 985 | + background: white; | |
| 986 | + content: ""; | |
| 987 | +} | |
| 988 | + | |
| 989 | +.progress-ring > div { | |
| 990 | + z-index: 1; | |
| 991 | + display: flex; | |
| 992 | + grid-area: 1 / 1; | |
| 993 | + align-items: baseline; | |
| 994 | +} | |
| 995 | + | |
| 996 | +.progress-ring strong { | |
| 997 | + font-family: "Barlow Condensed", sans-serif; | |
| 998 | + line-height: 1; | |
| 999 | +} | |
| 1000 | + | |
| 1001 | +.progress-ring span { | |
| 1002 | + color: var(--muted); | |
| 1003 | + font-size: 0.58em; | |
| 1004 | +} | |
| 1005 | + | |
| 1006 | +.progress-ring-small { width: 58px; height: 58px; } | |
| 1007 | +.progress-ring-small strong { font-size: 1.35rem; } | |
| 1008 | +.progress-ring-medium { width: 82px; height: 82px; } | |
| 1009 | +.progress-ring-medium strong { font-size: 1.9rem; } | |
| 1010 | +.progress-ring-large { width: 126px; height: 126px; } | |
| 1011 | +.progress-ring-large strong { font-size: 2.8rem; } | |
| 1012 | +.progress-ring-light { background: conic-gradient(var(--mango) var(--progress), rgba(255, 255, 255, 0.2) 0); } | |
| 1013 | +.progress-ring-light::before { background: rgba(20, 33, 61, 0.92); } | |
| 1014 | +.progress-ring-light strong, .progress-ring-light span { color: white; } | |
| 1015 | + | |
| 1016 | +/* Dashboard */ | |
| 1017 | +.tasteprint-ticket { | |
| 1018 | + position: relative; | |
| 1019 | + min-height: 390px; | |
| 1020 | + padding: 30px 34px 26px; | |
| 1021 | + overflow: hidden; | |
| 1022 | + border-radius: 22px; | |
| 1023 | + background: var(--blue); | |
| 1024 | + color: white; | |
| 1025 | + box-shadow: 0 24px 60px rgba(33, 85, 245, 0.23); | |
| 1026 | +} | |
| 1027 | + | |
| 1028 | +.ticket-heading { | |
| 1029 | + position: relative; | |
| 1030 | + z-index: 2; | |
| 1031 | + display: flex; | |
| 1032 | + align-items: flex-start; | |
| 1033 | + justify-content: space-between; | |
| 1034 | + gap: 20px; | |
| 1035 | +} | |
| 1036 | + | |
| 1037 | +.ticket-heading h2 { | |
| 1038 | + max-width: 530px; | |
| 1039 | + margin: 0; | |
| 1040 | + color: white; | |
| 1041 | + font-size: clamp(2.1rem, 4vw, 3.3rem); | |
| 1042 | +} | |
| 1043 | + | |
| 1044 | +.ticket-code { | |
| 1045 | + display: block; | |
| 1046 | + margin-bottom: 18px; | |
| 1047 | + color: #b9cbff; | |
| 1048 | + font-family: "IBM Plex Mono", monospace; | |
| 1049 | + font-size: 0.66rem; | |
| 1050 | + letter-spacing: 0.1em; | |
| 1051 | +} | |
| 1052 | + | |
| 1053 | +.ticket-share { | |
| 1054 | + display: inline-flex; | |
| 1055 | + align-items: center; | |
| 1056 | + gap: 7px; | |
| 1057 | + padding: 9px 12px; | |
| 1058 | + border: 1px solid rgba(255, 255, 255, 0.34); | |
| 1059 | + border-radius: 9px; | |
| 1060 | + font-size: 0.76rem; | |
| 1061 | + font-weight: 700; | |
| 1062 | +} | |
| 1063 | + | |
| 1064 | +.dashboard-map { | |
| 1065 | + position: absolute; | |
| 1066 | + z-index: 1; | |
| 1067 | + right: -40px; | |
| 1068 | + bottom: -64px; | |
| 1069 | + width: min(680px, 62%); | |
| 1070 | + opacity: 0.88; | |
| 1071 | +} | |
| 1072 | + | |
| 1073 | +.ticket-stats { | |
| 1074 | + position: absolute; | |
| 1075 | + z-index: 2; | |
| 1076 | + bottom: 26px; | |
| 1077 | + left: 34px; | |
| 1078 | + display: flex; | |
| 1079 | + gap: 32px; | |
| 1080 | +} | |
| 1081 | + | |
| 1082 | +.ticket-stats div { | |
| 1083 | + display: flex; | |
| 1084 | + flex-direction: column; | |
| 1085 | +} | |
| 1086 | + | |
| 1087 | +.ticket-stats strong { | |
| 1088 | + font-family: "Barlow Condensed", sans-serif; | |
| 1089 | + font-size: 1.8rem; | |
| 1090 | + line-height: 1; | |
| 1091 | +} | |
| 1092 | + | |
| 1093 | +.ticket-stats span { | |
| 1094 | + color: #c9d6fa; | |
| 1095 | + font-size: 0.65rem; | |
| 1096 | +} | |
| 1097 | + | |
| 1098 | +.ticket-notch { | |
| 1099 | + position: absolute; | |
| 1100 | + right: 25%; | |
| 1101 | + width: 26px; | |
| 1102 | + height: 26px; | |
| 1103 | + border-radius: 50%; | |
| 1104 | + background: var(--canvas); | |
| 1105 | +} | |
| 1106 | + | |
| 1107 | +.ticket-notch-top { top: -13px; } | |
| 1108 | +.ticket-notch-bottom { bottom: -13px; } | |
| 1109 | + | |
| 1110 | +.dashboard-grid { | |
| 1111 | + display: grid; | |
| 1112 | + grid-template-columns: 1.3fr 0.7fr; | |
| 1113 | + gap: 22px; | |
| 1114 | + margin-top: 24px; | |
| 1115 | +} | |
| 1116 | + | |
| 1117 | +.trip-meta-row { | |
| 1118 | + display: flex; | |
| 1119 | + justify-content: space-between; | |
| 1120 | + gap: 12px; | |
| 1121 | + margin: -5px 0 18px; | |
| 1122 | + color: var(--muted); | |
| 1123 | + font-size: 0.72rem; | |
| 1124 | +} | |
| 1125 | + | |
| 1126 | +.trip-meta-row span { | |
| 1127 | + display: inline-flex; | |
| 1128 | + align-items: center; | |
| 1129 | + gap: 6px; | |
| 1130 | +} | |
| 1131 | + | |
| 1132 | +.mission-list { | |
| 1133 | + display: flex; | |
| 1134 | + flex-direction: column; | |
| 1135 | + gap: 2px; | |
| 1136 | + margin: 0; | |
| 1137 | + padding: 0; | |
| 1138 | + list-style: none; | |
| 1139 | +} | |
| 1140 | + | |
| 1141 | +.mission-list li { | |
| 1142 | + display: flex; | |
| 1143 | + align-items: center; | |
| 1144 | + gap: 11px; | |
| 1145 | + padding: 10px 0; | |
| 1146 | + border-top: 1px solid #edf0f5; | |
| 1147 | +} | |
| 1148 | + | |
| 1149 | +.mission-number, .check-mark { | |
| 1150 | + display: grid; | |
| 1151 | + width: 26px; | |
| 1152 | + height: 26px; | |
| 1153 | + flex: 0 0 26px; | |
| 1154 | + place-items: center; | |
| 1155 | + border: 1px solid #c8d2e2; | |
| 1156 | + border-radius: 50%; | |
| 1157 | + color: var(--blue); | |
| 1158 | + font-family: "IBM Plex Mono", monospace; | |
| 1159 | + font-size: 0.64rem; | |
| 1160 | +} | |
| 1161 | + | |
| 1162 | +.mission-list li > span:nth-child(2) { | |
| 1163 | + display: flex; | |
| 1164 | + flex: 1; | |
| 1165 | + flex-direction: column; | |
| 1166 | +} | |
| 1167 | + | |
| 1168 | +.mission-list strong { font-size: 0.84rem; } | |
| 1169 | +.mission-list small { color: var(--muted); font-size: 0.66rem; } | |
| 1170 | +.mission-list button { border: 0; background: transparent; color: var(--blue); font-size: 0.73rem; font-weight: 700; } | |
| 1171 | +.mission-complete { opacity: 0.62; } | |
| 1172 | +.mission-complete strong { text-decoration: line-through; } | |
| 1173 | + | |
| 1174 | +.panel-link { | |
| 1175 | + margin-top: 18px; | |
| 1176 | + font-size: 0.78rem; | |
| 1177 | +} | |
| 1178 | + | |
| 1179 | +.coverage-list { | |
| 1180 | + display: flex; | |
| 1181 | + flex-direction: column; | |
| 1182 | +} | |
| 1183 | + | |
| 1184 | +.coverage-list a { | |
| 1185 | + display: flex; | |
| 1186 | + align-items: center; | |
| 1187 | + gap: 12px; | |
| 1188 | + padding: 10px 0; | |
| 1189 | + border-top: 1px solid #edf0f5; | |
| 1190 | +} | |
| 1191 | + | |
| 1192 | +.coverage-list a > span:not(.progress-ring) { | |
| 1193 | + display: flex; | |
| 1194 | + flex: 1; | |
| 1195 | + flex-direction: column; | |
| 1196 | +} | |
| 1197 | + | |
| 1198 | +.coverage-list strong { font-size: 0.82rem; } | |
| 1199 | +.coverage-list small { color: var(--muted); font-size: 0.66rem; } | |
| 1200 | + | |
| 1201 | +.compact-empty { | |
| 1202 | + display: flex; | |
| 1203 | + min-height: 170px; | |
| 1204 | + flex-direction: column; | |
| 1205 | + align-items: flex-start; | |
| 1206 | + justify-content: center; | |
| 1207 | + gap: 12px; | |
| 1208 | + color: var(--muted); | |
| 1209 | +} | |
| 1210 | + | |
| 1211 | +.compact-empty p { | |
| 1212 | + max-width: 400px; | |
| 1213 | + margin: 0; | |
| 1214 | + font-size: 0.86rem; | |
| 1215 | + line-height: 1.6; | |
| 1216 | +} | |
| 1217 | + | |
| 1218 | +.dashboard-section { | |
| 1219 | + margin-top: 48px; | |
| 1220 | +} | |
| 1221 | + | |
| 1222 | +.dish-grid { | |
| 1223 | + display: grid; | |
| 1224 | + gap: 18px; | |
| 1225 | +} | |
| 1226 | + | |
| 1227 | +.dish-grid-two { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 1228 | +.dish-grid-three { grid-template-columns: repeat(3, minmax(0, 1fr)); } | |
| 1229 | + | |
| 1230 | +.dish-card { | |
| 1231 | + overflow: hidden; | |
| 1232 | + border: 1px solid var(--line); | |
| 1233 | + border-radius: 15px; | |
| 1234 | + background: white; | |
| 1235 | + transition: transform 160ms ease, box-shadow 160ms ease; | |
| 1236 | +} | |
| 1237 | + | |
| 1238 | +.dish-card:hover { | |
| 1239 | + transform: translateY(-2px); | |
| 1240 | + box-shadow: 0 12px 30px rgba(20, 33, 61, 0.08); | |
| 1241 | +} | |
| 1242 | + | |
| 1243 | +.dish-card-band { | |
| 1244 | + display: flex; | |
| 1245 | + align-items: center; | |
| 1246 | + justify-content: space-between; | |
| 1247 | + padding: 9px 14px; | |
| 1248 | + color: white; | |
| 1249 | + font-family: "IBM Plex Mono", monospace; | |
| 1250 | + font-size: 0.6rem; | |
| 1251 | + letter-spacing: 0.08em; | |
| 1252 | + text-transform: uppercase; | |
| 1253 | +} | |
| 1254 | + | |
| 1255 | +.dish-card-body { | |
| 1256 | + padding: 18px; | |
| 1257 | +} | |
| 1258 | + | |
| 1259 | +.dish-card-title { | |
| 1260 | + display: flex; | |
| 1261 | + justify-content: space-between; | |
| 1262 | + gap: 12px; | |
| 1263 | +} | |
| 1264 | + | |
| 1265 | +.dish-card-title h3 { | |
| 1266 | + margin-bottom: 2px; | |
| 1267 | + font-size: 1.6rem; | |
| 1268 | +} | |
| 1269 | + | |
| 1270 | +.dish-card-title p, .dish-description { | |
| 1271 | + color: var(--muted); | |
| 1272 | +} | |
| 1273 | + | |
| 1274 | +.dish-card-title p { | |
| 1275 | + margin-bottom: 10px; | |
| 1276 | + font-size: 0.72rem; | |
| 1277 | +} | |
| 1278 | + | |
| 1279 | +.dish-description { | |
| 1280 | + min-height: 58px; | |
| 1281 | + margin-bottom: 14px; | |
| 1282 | + font-size: 0.82rem; | |
| 1283 | + line-height: 1.55; | |
| 1284 | +} | |
| 1285 | + | |
| 1286 | +.tried-seal { | |
| 1287 | + display: grid; | |
| 1288 | + width: 30px; | |
| 1289 | + height: 30px; | |
| 1290 | + place-items: center; | |
| 1291 | + border-radius: 50%; | |
| 1292 | + background: #d8f6ed; | |
| 1293 | + color: #12644f; | |
| 1294 | +} | |
| 1295 | + | |
| 1296 | +.dish-meta { | |
| 1297 | + display: flex; | |
| 1298 | + flex-wrap: wrap; | |
| 1299 | + gap: 7px; | |
| 1300 | +} | |
| 1301 | + | |
| 1302 | +.dish-meta span { | |
| 1303 | + display: inline-flex; | |
| 1304 | + align-items: center; | |
| 1305 | + gap: 4px; | |
| 1306 | + padding: 4px 7px; | |
| 1307 | + border-radius: 6px; | |
| 1308 | + background: #f1f4f8; | |
| 1309 | + color: #5d687d; | |
| 1310 | + font-size: 0.61rem; | |
| 1311 | +} | |
| 1312 | + | |
| 1313 | +.text-button { | |
| 1314 | + display: inline-flex; | |
| 1315 | + align-items: center; | |
| 1316 | + gap: 5px; | |
| 1317 | + margin-top: 15px; | |
| 1318 | + padding: 0; | |
| 1319 | + border: 0; | |
| 1320 | + background: transparent; | |
| 1321 | + color: var(--blue); | |
| 1322 | + font-size: 0.72rem; | |
| 1323 | + font-weight: 700; | |
| 1324 | +} | |
| 1325 | + | |
| 1326 | +.dish-card-tried { | |
| 1327 | + background: #f9fbfb; | |
| 1328 | +} | |
| 1329 | + | |
| 1330 | +.recent-tastings { | |
| 1331 | + display: grid; | |
| 1332 | + grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| 1333 | + gap: 12px; | |
| 1334 | +} | |
| 1335 | + | |
| 1336 | +.recent-tastings article { | |
| 1337 | + display: flex; | |
| 1338 | + align-items: center; | |
| 1339 | + gap: 11px; | |
| 1340 | + padding: 10px; | |
| 1341 | + border: 1px solid var(--line); | |
| 1342 | + border-radius: 12px; | |
| 1343 | + background: white; | |
| 1344 | +} | |
| 1345 | + | |
| 1346 | +.recent-photo { | |
| 1347 | + display: grid; | |
| 1348 | + width: 54px; | |
| 1349 | + height: 54px; | |
| 1350 | + flex: 0 0 54px; | |
| 1351 | + place-items: center; | |
| 1352 | + overflow: hidden; | |
| 1353 | + border-radius: 9px; | |
| 1354 | + color: white; | |
| 1355 | + font-family: "IBM Plex Mono", monospace; | |
| 1356 | + font-size: 0.7rem; | |
| 1357 | +} | |
| 1358 | + | |
| 1359 | +.recent-photo img { width: 100%; height: 100%; object-fit: cover; } | |
| 1360 | +.recent-tastings article > div:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; } | |
| 1361 | +.recent-tastings strong { font-size: 0.8rem; } | |
| 1362 | +.recent-tastings span { display: flex; align-items: center; gap: 3px; color: var(--muted); font-size: 0.66rem; } | |
| 1363 | +.recent-tastings time { color: var(--muted); font-size: 0.66rem; } | |
| 1364 | + | |
| 1365 | +/* Explore and destinations */ | |
| 1366 | +.atlas-map-panel { | |
| 1367 | + position: relative; | |
| 1368 | + min-height: 380px; | |
| 1369 | + padding: 18px 28px; | |
| 1370 | + overflow: hidden; | |
| 1371 | + border-radius: 20px; | |
| 1372 | + background: #eaf0fb; | |
| 1373 | +} | |
| 1374 | + | |
| 1375 | +.atlas-map-panel .world-map { | |
| 1376 | + width: min(780px, 78%); | |
| 1377 | + margin: 0 auto; | |
| 1378 | +} | |
| 1379 | + | |
| 1380 | +.atlas-map-copy { | |
| 1381 | + position: absolute; | |
| 1382 | + top: 27px; | |
| 1383 | + left: 30px; | |
| 1384 | + padding: 15px 17px; | |
| 1385 | + border: 1px solid rgba(255, 255, 255, 0.8); | |
| 1386 | + border-radius: 12px; | |
| 1387 | + background: rgba(255, 255, 255, 0.82); | |
| 1388 | + box-shadow: 0 10px 28px rgba(20, 33, 61, 0.08); | |
| 1389 | + backdrop-filter: blur(10px); | |
| 1390 | +} | |
| 1391 | + | |
| 1392 | +.atlas-map-copy strong { | |
| 1393 | + display: block; | |
| 1394 | + font-family: "Barlow Condensed", sans-serif; | |
| 1395 | + font-size: 2.2rem; | |
| 1396 | +} | |
| 1397 | + | |
| 1398 | +.atlas-map-copy p { | |
| 1399 | + margin: 0; | |
| 1400 | + color: var(--muted); | |
| 1401 | + font-size: 0.7rem; | |
| 1402 | +} | |
| 1403 | + | |
| 1404 | +.filter-bar, .journal-toolbar { | |
| 1405 | + display: flex; | |
| 1406 | + align-items: center; | |
| 1407 | + justify-content: space-between; | |
| 1408 | + gap: 18px; | |
| 1409 | + margin: 28px 0 22px; | |
| 1410 | +} | |
| 1411 | + | |
| 1412 | +.search-field { | |
| 1413 | + position: relative; | |
| 1414 | + display: flex; | |
| 1415 | + width: min(360px, 100%); | |
| 1416 | + align-items: center; | |
| 1417 | +} | |
| 1418 | + | |
| 1419 | +.search-field svg { | |
| 1420 | + position: absolute; | |
| 1421 | + left: 13px; | |
| 1422 | + color: var(--muted); | |
| 1423 | +} | |
| 1424 | + | |
| 1425 | +.search-field input { | |
| 1426 | + padding-left: 40px; | |
| 1427 | +} | |
| 1428 | + | |
| 1429 | +.filter-chips { | |
| 1430 | + display: flex; | |
| 1431 | + flex-wrap: wrap; | |
| 1432 | + justify-content: flex-end; | |
| 1433 | + gap: 6px; | |
| 1434 | +} | |
| 1435 | + | |
| 1436 | +.filter-chips button { | |
| 1437 | + padding: 7px 11px; | |
| 1438 | + border: 1px solid var(--line); | |
| 1439 | + border-radius: 999px; | |
| 1440 | + background: white; | |
| 1441 | + color: var(--muted); | |
| 1442 | + font-size: 0.68rem; | |
| 1443 | + font-weight: 700; | |
| 1444 | +} | |
| 1445 | + | |
| 1446 | +.filter-chips button.active { | |
| 1447 | + border-color: var(--blue); | |
| 1448 | + background: var(--blue); | |
| 1449 | + color: white; | |
| 1450 | +} | |
| 1451 | + | |
| 1452 | +.destination-grid { | |
| 1453 | + display: grid; | |
| 1454 | + grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| 1455 | + gap: 18px; | |
| 1456 | +} | |
| 1457 | + | |
| 1458 | +.destination-card { | |
| 1459 | + overflow: hidden; | |
| 1460 | + border: 1px solid var(--line); | |
| 1461 | + border-radius: 17px; | |
| 1462 | + background: white; | |
| 1463 | + transition: transform 160ms ease, box-shadow 160ms ease; | |
| 1464 | +} | |
| 1465 | + | |
| 1466 | +.destination-card:hover { | |
| 1467 | + transform: translateY(-3px); | |
| 1468 | + box-shadow: var(--shadow); | |
| 1469 | +} | |
| 1470 | + | |
| 1471 | +.destination-card-top { | |
| 1472 | + position: relative; | |
| 1473 | + display: flex; | |
| 1474 | + min-height: 125px; | |
| 1475 | + align-items: flex-start; | |
| 1476 | + justify-content: space-between; | |
| 1477 | + padding: 18px; | |
| 1478 | + overflow: hidden; | |
| 1479 | + color: white; | |
| 1480 | +} | |
| 1481 | + | |
| 1482 | +.destination-code { | |
| 1483 | + font-family: "Barlow Condensed", sans-serif; | |
| 1484 | + font-size: 3.4rem; | |
| 1485 | + font-weight: 700; | |
| 1486 | + line-height: 1; | |
| 1487 | +} | |
| 1488 | + | |
| 1489 | +.destination-local { | |
| 1490 | + position: relative; | |
| 1491 | + z-index: 1; | |
| 1492 | + font-family: "IBM Plex Mono", monospace; | |
| 1493 | + font-size: 0.63rem; | |
| 1494 | +} | |
| 1495 | + | |
| 1496 | +.destination-contours { | |
| 1497 | + position: absolute; | |
| 1498 | + right: -22px; | |
| 1499 | + bottom: -35px; | |
| 1500 | + width: 155px; | |
| 1501 | + height: 105px; | |
| 1502 | + border: 1px solid rgba(255, 255, 255, 0.28); | |
| 1503 | + border-radius: 52% 48% 41% 59%; | |
| 1504 | + box-shadow: 0 0 0 15px rgba(255, 255, 255, 0.08), 0 0 0 30px rgba(255, 255, 255, 0.06); | |
| 1505 | + transform: rotate(-13deg); | |
| 1506 | +} | |
| 1507 | + | |
| 1508 | +.destination-card-body { | |
| 1509 | + display: grid; | |
| 1510 | + grid-template-columns: 1fr auto; | |
| 1511 | + gap: 14px; | |
| 1512 | + padding: 19px; | |
| 1513 | +} | |
| 1514 | + | |
| 1515 | +.destination-card-body h2 { | |
| 1516 | + margin: 0; | |
| 1517 | +} | |
| 1518 | + | |
| 1519 | +.destination-card-body > p { | |
| 1520 | + grid-column: 1 / -1; | |
| 1521 | + min-height: 62px; | |
| 1522 | + margin: 0; | |
| 1523 | + color: var(--muted); | |
| 1524 | + font-size: 0.77rem; | |
| 1525 | + line-height: 1.55; | |
| 1526 | +} | |
| 1527 | + | |
| 1528 | +.destination-card-body footer { | |
| 1529 | + display: flex; | |
| 1530 | + grid-column: 1 / -1; | |
| 1531 | + align-items: center; | |
| 1532 | + justify-content: space-between; | |
| 1533 | + padding-top: 12px; | |
| 1534 | + border-top: 1px solid #edf0f5; | |
| 1535 | + color: var(--muted); | |
| 1536 | + font-size: 0.69rem; | |
| 1537 | +} | |
| 1538 | + | |
| 1539 | +.destination-hero { | |
| 1540 | + --destination-accent: var(--blue); | |
| 1541 | + position: relative; | |
| 1542 | + display: grid; | |
| 1543 | + min-height: 340px; | |
| 1544 | + grid-template-columns: 160px 1fr 180px; | |
| 1545 | + align-items: center; | |
| 1546 | + gap: 25px; | |
| 1547 | + padding: 35px; | |
| 1548 | + overflow: hidden; | |
| 1549 | + border-radius: 22px; | |
| 1550 | + background: var(--destination-accent); | |
| 1551 | + color: white; | |
| 1552 | +} | |
| 1553 | + | |
| 1554 | +.destination-hero h1 { | |
| 1555 | + margin: 0; | |
| 1556 | + color: white; | |
| 1557 | +} | |
| 1558 | + | |
| 1559 | +.destination-hero-main > p:not(.destination-local-name) { | |
| 1560 | + max-width: 560px; | |
| 1561 | + color: rgba(255, 255, 255, 0.82); | |
| 1562 | + line-height: 1.6; | |
| 1563 | +} | |
| 1564 | + | |
| 1565 | +.destination-local-name { | |
| 1566 | + margin: 5px 0 16px; | |
| 1567 | + color: white; | |
| 1568 | + font-family: "IBM Plex Mono", monospace; | |
| 1569 | + font-size: 0.76rem; | |
| 1570 | +} | |
| 1571 | + | |
| 1572 | +.destination-hero-code { | |
| 1573 | + color: rgba(255, 255, 255, 0.16); | |
| 1574 | + font-family: "Barlow Condensed", sans-serif; | |
| 1575 | + font-size: 8.5rem; | |
| 1576 | + font-weight: 700; | |
| 1577 | + transform: rotate(-90deg); | |
| 1578 | +} | |
| 1579 | + | |
| 1580 | +.destination-coverage-block { | |
| 1581 | + position: relative; | |
| 1582 | + z-index: 1; | |
| 1583 | + display: flex; | |
| 1584 | + flex-direction: column; | |
| 1585 | + align-items: center; | |
| 1586 | + gap: 10px; | |
| 1587 | + font-size: 0.7rem; | |
| 1588 | + text-align: center; | |
| 1589 | +} | |
| 1590 | + | |
| 1591 | +.destination-hero .progress-ring::before { | |
| 1592 | + background: var(--destination-accent); | |
| 1593 | +} | |
| 1594 | + | |
| 1595 | +.destination-hero .progress-ring strong, .destination-hero .progress-ring span { | |
| 1596 | + color: white; | |
| 1597 | +} | |
| 1598 | + | |
| 1599 | +.destination-stamp-outline { | |
| 1600 | + position: absolute; | |
| 1601 | + right: -65px; | |
| 1602 | + bottom: -90px; | |
| 1603 | + width: 290px; | |
| 1604 | + height: 290px; | |
| 1605 | + border: 3px double rgba(255, 255, 255, 0.13); | |
| 1606 | + border-radius: 50%; | |
| 1607 | +} | |
| 1608 | + | |
| 1609 | +.culture-note { | |
| 1610 | + display: flex; | |
| 1611 | + gap: 14px; | |
| 1612 | + margin: 18px 0 44px; | |
| 1613 | + padding: 17px 20px; | |
| 1614 | + border: 1px solid #d8e3fb; | |
| 1615 | + border-radius: 12px; | |
| 1616 | + background: #eef3ff; | |
| 1617 | + color: var(--blue-dark); | |
| 1618 | +} | |
| 1619 | + | |
| 1620 | +.culture-note p { | |
| 1621 | + margin: 3px 0 0; | |
| 1622 | + color: #58667f; | |
| 1623 | + font-size: 0.76rem; | |
| 1624 | + line-height: 1.55; | |
| 1625 | +} | |
| 1626 | + | |
| 1627 | +/* Journal */ | |
| 1628 | +.journal-toolbar > span { | |
| 1629 | + color: var(--muted); | |
| 1630 | + font-family: "IBM Plex Mono", monospace; | |
| 1631 | + font-size: 0.66rem; | |
| 1632 | +} | |
| 1633 | + | |
| 1634 | +.journal-list { | |
| 1635 | + display: flex; | |
| 1636 | + flex-direction: column; | |
| 1637 | + gap: 12px; | |
| 1638 | +} | |
| 1639 | + | |
| 1640 | +.journal-entry { | |
| 1641 | + display: grid; | |
| 1642 | + grid-template-columns: 132px 1fr auto; | |
| 1643 | + gap: 18px; | |
| 1644 | + min-height: 148px; | |
| 1645 | + padding: 11px; | |
| 1646 | + border: 1px solid var(--line); | |
| 1647 | + border-radius: 15px; | |
| 1648 | + background: white; | |
| 1649 | +} | |
| 1650 | + | |
| 1651 | +.journal-image { | |
| 1652 | + display: grid; | |
| 1653 | + min-height: 126px; | |
| 1654 | + place-items: center; | |
| 1655 | + overflow: hidden; | |
| 1656 | + border-radius: 10px; | |
| 1657 | + color: white; | |
| 1658 | + font-family: "Barlow Condensed", sans-serif; | |
| 1659 | + font-size: 2.5rem; | |
| 1660 | + font-weight: 700; | |
| 1661 | +} | |
| 1662 | + | |
| 1663 | +.journal-image img { | |
| 1664 | + width: 100%; | |
| 1665 | + height: 100%; | |
| 1666 | + object-fit: cover; | |
| 1667 | +} | |
| 1668 | + | |
| 1669 | +.journal-main { | |
| 1670 | + padding: 11px 0; | |
| 1671 | +} | |
| 1672 | + | |
| 1673 | +.journal-title-row, .journal-place { | |
| 1674 | + display: flex; | |
| 1675 | + align-items: center; | |
| 1676 | + justify-content: space-between; | |
| 1677 | + gap: 15px; | |
| 1678 | +} | |
| 1679 | + | |
| 1680 | +.journal-title-row h2 { | |
| 1681 | + margin-bottom: 7px; | |
| 1682 | +} | |
| 1683 | + | |
| 1684 | +.journal-rating { | |
| 1685 | + display: inline-flex; | |
| 1686 | + align-items: center; | |
| 1687 | + gap: 4px; | |
| 1688 | + padding: 6px 9px; | |
| 1689 | + border-radius: 8px; | |
| 1690 | + background: #fff5d5; | |
| 1691 | + color: #8a5a00; | |
| 1692 | + font-size: 0.7rem; | |
| 1693 | + font-weight: 700; | |
| 1694 | +} | |
| 1695 | + | |
| 1696 | +.journal-place { | |
| 1697 | + justify-content: flex-start; | |
| 1698 | + color: var(--muted); | |
| 1699 | + font-size: 0.7rem; | |
| 1700 | +} | |
| 1701 | + | |
| 1702 | +.journal-place time { | |
| 1703 | + margin-left: auto; | |
| 1704 | +} | |
| 1705 | + | |
| 1706 | +.journal-main blockquote { | |
| 1707 | + margin: 14px 0 0; | |
| 1708 | + padding-left: 12px; | |
| 1709 | + border-left: 2px solid var(--blue); | |
| 1710 | + color: #47536a; | |
| 1711 | + font-size: 0.8rem; | |
| 1712 | + font-style: italic; | |
| 1713 | + line-height: 1.55; | |
| 1714 | +} | |
| 1715 | + | |
| 1716 | +.journal-actions { | |
| 1717 | + display: flex; | |
| 1718 | + gap: 7px; | |
| 1719 | + padding: 8px 5px; | |
| 1720 | +} | |
| 1721 | + | |
| 1722 | +/* Trips */ | |
| 1723 | +.trip-list { | |
| 1724 | + display: grid; | |
| 1725 | + grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| 1726 | + gap: 20px; | |
| 1727 | +} | |
| 1728 | + | |
| 1729 | +.trip-card { | |
| 1730 | + --destination-accent: var(--blue); | |
| 1731 | + overflow: hidden; | |
| 1732 | + border: 1px solid var(--line); | |
| 1733 | + border-top: 7px solid var(--destination-accent); | |
| 1734 | + border-radius: 16px; | |
| 1735 | + background: white; | |
| 1736 | + transition: transform 160ms ease, box-shadow 160ms ease; | |
| 1737 | +} | |
| 1738 | + | |
| 1739 | +.trip-card:hover { | |
| 1740 | + transform: translateY(-3px); | |
| 1741 | + box-shadow: var(--shadow); | |
| 1742 | +} | |
| 1743 | + | |
| 1744 | +.trip-card-route { | |
| 1745 | + padding: 15px 20px 8px; | |
| 1746 | + color: var(--muted); | |
| 1747 | +} | |
| 1748 | + | |
| 1749 | +.trip-card-main { | |
| 1750 | + display: flex; | |
| 1751 | + align-items: center; | |
| 1752 | + justify-content: space-between; | |
| 1753 | + gap: 16px; | |
| 1754 | + padding: 16px 20px; | |
| 1755 | +} | |
| 1756 | + | |
| 1757 | +.trip-card-main h2 { | |
| 1758 | + margin: 8px 0 2px; | |
| 1759 | + font-size: 2.15rem; | |
| 1760 | +} | |
| 1761 | + | |
| 1762 | +.trip-card-main p { | |
| 1763 | + margin: 0; | |
| 1764 | + color: var(--muted); | |
| 1765 | + font-size: 0.78rem; | |
| 1766 | +} | |
| 1767 | + | |
| 1768 | +.trip-card-meta { | |
| 1769 | + display: flex; | |
| 1770 | + flex-wrap: wrap; | |
| 1771 | + gap: 14px; | |
| 1772 | + padding: 0 20px 16px; | |
| 1773 | + color: var(--muted); | |
| 1774 | + font-size: 0.68rem; | |
| 1775 | +} | |
| 1776 | + | |
| 1777 | +.trip-card-meta span, .trip-card footer span { | |
| 1778 | + display: inline-flex; | |
| 1779 | + align-items: center; | |
| 1780 | + gap: 5px; | |
| 1781 | +} | |
| 1782 | + | |
| 1783 | +.trip-card footer { | |
| 1784 | + display: flex; | |
| 1785 | + align-items: center; | |
| 1786 | + justify-content: space-between; | |
| 1787 | + padding: 13px 20px; | |
| 1788 | + border-top: 1px dashed var(--line); | |
| 1789 | + color: var(--blue); | |
| 1790 | + font-size: 0.73rem; | |
| 1791 | + font-weight: 700; | |
| 1792 | +} | |
| 1793 | + | |
| 1794 | +.trip-detail-hero { | |
| 1795 | + --destination-accent: var(--blue); | |
| 1796 | + position: relative; | |
| 1797 | + display: flex; | |
| 1798 | + align-items: center; | |
| 1799 | + justify-content: space-between; | |
| 1800 | + gap: 30px; | |
| 1801 | + padding: 35px; | |
| 1802 | + border-radius: 20px; | |
| 1803 | + background: var(--destination-accent); | |
| 1804 | + color: white; | |
| 1805 | +} | |
| 1806 | + | |
| 1807 | +.trip-detail-labels { | |
| 1808 | + display: flex; | |
| 1809 | + align-items: center; | |
| 1810 | + gap: 9px; | |
| 1811 | +} | |
| 1812 | + | |
| 1813 | +.trip-detail-labels > span:last-child { | |
| 1814 | + font-family: "IBM Plex Mono", monospace; | |
| 1815 | + font-size: 0.7rem; | |
| 1816 | +} | |
| 1817 | + | |
| 1818 | +.trip-detail-hero h1 { | |
| 1819 | + margin: 14px 0 0; | |
| 1820 | + color: white; | |
| 1821 | +} | |
| 1822 | + | |
| 1823 | +.trip-detail-hero > div:first-child > p { | |
| 1824 | + margin: 0 0 18px; | |
| 1825 | + color: rgba(255, 255, 255, 0.76); | |
| 1826 | +} | |
| 1827 | + | |
| 1828 | +.trip-detail-dates { | |
| 1829 | + display: inline-flex; | |
| 1830 | + align-items: center; | |
| 1831 | + gap: 6px; | |
| 1832 | + font-size: 0.75rem; | |
| 1833 | +} | |
| 1834 | + | |
| 1835 | +.trip-detail-score { | |
| 1836 | + display: flex; | |
| 1837 | + flex-direction: column; | |
| 1838 | + align-items: center; | |
| 1839 | + gap: 8px; | |
| 1840 | + font-size: 0.65rem; | |
| 1841 | +} | |
| 1842 | + | |
| 1843 | +.trip-detail-score .progress-ring::before { background: var(--destination-accent); } | |
| 1844 | +.trip-detail-score .progress-ring strong, .trip-detail-score .progress-ring span { color: white; } | |
| 1845 | + | |
| 1846 | +.trip-detail-actions { | |
| 1847 | + position: absolute; | |
| 1848 | + top: 18px; | |
| 1849 | + right: 18px; | |
| 1850 | + display: flex; | |
| 1851 | + gap: 6px; | |
| 1852 | +} | |
| 1853 | + | |
| 1854 | +.trip-detail-actions .icon-button { | |
| 1855 | + border-color: rgba(255, 255, 255, 0.3); | |
| 1856 | + background: rgba(255, 255, 255, 0.12); | |
| 1857 | + color: white; | |
| 1858 | +} | |
| 1859 | + | |
| 1860 | +.mission-sheet { | |
| 1861 | + margin-top: 22px; | |
| 1862 | + padding: 28px 30px; | |
| 1863 | + border: 1px solid var(--line); | |
| 1864 | + border-radius: 18px; | |
| 1865 | + background: white; | |
| 1866 | +} | |
| 1867 | + | |
| 1868 | +.mission-sheet > header { | |
| 1869 | + display: flex; | |
| 1870 | + align-items: flex-end; | |
| 1871 | + justify-content: space-between; | |
| 1872 | + gap: 18px; | |
| 1873 | + margin-bottom: 20px; | |
| 1874 | +} | |
| 1875 | + | |
| 1876 | +.mission-sheet h2 { margin: 0; } | |
| 1877 | +.mission-route { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: 0.68rem; } | |
| 1878 | +.mission-sheet ol { margin: 0; padding: 0; list-style: none; } | |
| 1879 | + | |
| 1880 | +.mission-sheet li { | |
| 1881 | + display: grid; | |
| 1882 | + grid-template-columns: 48px 1fr auto; | |
| 1883 | + align-items: center; | |
| 1884 | + gap: 18px; | |
| 1885 | + padding: 19px 0; | |
| 1886 | + border-top: 1px dashed #cdd6e3; | |
| 1887 | +} | |
| 1888 | + | |
| 1889 | +.mission-sequence { | |
| 1890 | + display: grid; | |
| 1891 | + width: 42px; | |
| 1892 | + height: 42px; | |
| 1893 | + place-items: center; | |
| 1894 | + border: 1px solid #b7c3d4; | |
| 1895 | + border-radius: 50%; | |
| 1896 | + color: var(--blue); | |
| 1897 | + font-family: "IBM Plex Mono", monospace; | |
| 1898 | + font-size: 0.7rem; | |
| 1899 | +} | |
| 1900 | + | |
| 1901 | +.mission-dish-copy > span { | |
| 1902 | + color: var(--blue); | |
| 1903 | + font-family: "IBM Plex Mono", monospace; | |
| 1904 | + font-size: 0.59rem; | |
| 1905 | + letter-spacing: 0.08em; | |
| 1906 | + text-transform: uppercase; | |
| 1907 | +} | |
| 1908 | + | |
| 1909 | +.mission-dish-copy h3 { margin: 4px 0 6px; font-size: 1.5rem; } | |
| 1910 | +.mission-dish-copy p { margin: 0 0 5px; color: #4f5b71; font-size: 0.78rem; line-height: 1.5; } | |
| 1911 | +.mission-dish-copy small { color: var(--muted); font-size: 0.68rem; } | |
| 1912 | +.mission-done { color: #12644f; font-size: 0.72rem; font-weight: 700; } | |
| 1913 | + | |
| 1914 | +/* Dialogs */ | |
| 1915 | +.dialog-backdrop { | |
| 1916 | + position: fixed; | |
| 1917 | + z-index: 100; | |
| 1918 | + inset: 0; | |
| 1919 | + display: grid; | |
| 1920 | + place-items: center; | |
| 1921 | + padding: 20px; | |
| 1922 | + overflow-y: auto; | |
| 1923 | + background: rgba(13, 25, 48, 0.68); | |
| 1924 | + backdrop-filter: blur(5px); | |
| 1925 | +} | |
| 1926 | + | |
| 1927 | +.taste-dialog, .small-dialog { | |
| 1928 | + width: min(660px, 100%); | |
| 1929 | + max-height: calc(100vh - 40px); | |
| 1930 | + overflow-y: auto; | |
| 1931 | + border-radius: 18px; | |
| 1932 | + background: white; | |
| 1933 | + box-shadow: 0 30px 90px rgba(6, 15, 34, 0.34); | |
| 1934 | +} | |
| 1935 | + | |
| 1936 | +.small-dialog { width: min(520px, 100%); } | |
| 1937 | + | |
| 1938 | +.dialog-header { | |
| 1939 | + position: sticky; | |
| 1940 | + z-index: 2; | |
| 1941 | + top: 0; | |
| 1942 | + display: flex; | |
| 1943 | + align-items: flex-start; | |
| 1944 | + justify-content: space-between; | |
| 1945 | + gap: 20px; | |
| 1946 | + padding: 22px 24px 16px; | |
| 1947 | + border-bottom: 1px solid var(--line); | |
| 1948 | + background: white; | |
| 1949 | +} | |
| 1950 | + | |
| 1951 | +.dialog-header h2 { margin: 0; } | |
| 1952 | +.dialog-form { padding: 22px 24px 24px; } | |
| 1953 | +.dialog-actions { display: flex; justify-content: flex-end; gap: 9px; padding-top: 5px; } | |
| 1954 | + | |
| 1955 | +.rating-field { | |
| 1956 | + margin: 0; | |
| 1957 | + padding: 0; | |
| 1958 | + border: 0; | |
| 1959 | +} | |
| 1960 | + | |
| 1961 | +.rating-options { | |
| 1962 | + display: grid; | |
| 1963 | + grid-template-columns: repeat(5, 1fr); | |
| 1964 | + gap: 7px; | |
| 1965 | +} | |
| 1966 | + | |
| 1967 | +.rating-options button { | |
| 1968 | + height: 40px; | |
| 1969 | + border: 1px solid #c8d1df; | |
| 1970 | + border-radius: 8px; | |
| 1971 | + background: white; | |
| 1972 | + color: var(--muted); | |
| 1973 | + font-family: "IBM Plex Mono", monospace; | |
| 1974 | + font-size: 0.72rem; | |
| 1975 | +} | |
| 1976 | + | |
| 1977 | +.rating-options button.rating-active { | |
| 1978 | + border-color: var(--blue); | |
| 1979 | + background: var(--blue); | |
| 1980 | + color: white; | |
| 1981 | +} | |
| 1982 | + | |
| 1983 | +.photo-location-row { | |
| 1984 | + display: grid; | |
| 1985 | + grid-template-columns: 1fr 1fr; | |
| 1986 | + gap: 10px; | |
| 1987 | +} | |
| 1988 | + | |
| 1989 | +.photo-picker, .location-button { | |
| 1990 | + display: flex; | |
| 1991 | + min-height: 72px; | |
| 1992 | + align-items: center; | |
| 1993 | + justify-content: center; | |
| 1994 | + gap: 9px; | |
| 1995 | + overflow: hidden; | |
| 1996 | + border: 1px dashed #b9c5d8; | |
| 1997 | + border-radius: 10px; | |
| 1998 | + background: #f8fafe; | |
| 1999 | + color: var(--muted); | |
| 2000 | + font-size: 0.72rem; | |
| 2001 | + font-weight: 700; | |
| 2002 | +} | |
| 2003 | + | |
| 2004 | +.photo-control { | |
| 2005 | + display: flex; | |
| 2006 | + min-width: 0; | |
| 2007 | + flex-direction: column; | |
| 2008 | + gap: 5px; | |
| 2009 | +} | |
| 2010 | + | |
| 2011 | +.photo-control .photo-picker { | |
| 2012 | + flex: 1; | |
| 2013 | +} | |
| 2014 | + | |
| 2015 | +.remove-photo-button { | |
| 2016 | + align-self: flex-start; | |
| 2017 | + padding: 2px 0; | |
| 2018 | + border: 0; | |
| 2019 | + background: transparent; | |
| 2020 | + color: #b9232e; | |
| 2021 | + font-size: 0.66rem; | |
| 2022 | + font-weight: 700; | |
| 2023 | +} | |
| 2024 | + | |
| 2025 | +.photo-picker { | |
| 2026 | + position: relative; | |
| 2027 | + margin: 0; | |
| 2028 | + cursor: pointer; | |
| 2029 | +} | |
| 2030 | + | |
| 2031 | +.photo-picker > span { margin: 0; color: inherit; } | |
| 2032 | +.photo-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; } | |
| 2033 | +.photo-picker img { width: 62px; height: 62px; object-fit: cover; } | |
| 2034 | +.location-set { border-style: solid; border-color: #86cdb9; background: #e7f7f2; color: #12644f; } | |
| 2035 | + | |
| 2036 | +/* Challenges */ | |
| 2037 | +.join-strip { | |
| 2038 | + display: grid; | |
| 2039 | + grid-template-columns: 1fr auto; | |
| 2040 | + align-items: center; | |
| 2041 | + gap: 14px 24px; | |
| 2042 | + margin-bottom: 28px; | |
| 2043 | + padding: 17px 20px; | |
| 2044 | + border-radius: 13px; | |
| 2045 | + background: var(--ink); | |
| 2046 | + color: white; | |
| 2047 | +} | |
| 2048 | + | |
| 2049 | +.join-strip > div, .join-strip form { | |
| 2050 | + display: flex; | |
| 2051 | + align-items: center; | |
| 2052 | + gap: 11px; | |
| 2053 | +} | |
| 2054 | + | |
| 2055 | +.join-strip > div span { display: flex; flex-direction: column; } | |
| 2056 | +.join-strip strong { font-size: 0.84rem; } | |
| 2057 | +.join-strip small { color: #aebbd0; font-size: 0.67rem; } | |
| 2058 | +.join-strip form input { width: 130px; border-color: #3c4d6a; background: #223454; color: white; font-family: "IBM Plex Mono", monospace; letter-spacing: 0.13em; text-align: center; text-transform: uppercase; } | |
| 2059 | +.join-strip .button-ink { border: 1px solid #60708b; } | |
| 2060 | +.join-strip .form-error { grid-column: 1 / -1; color: #ffb8b2; } | |
| 2061 | + | |
| 2062 | +.challenge-grid { | |
| 2063 | + display: grid; | |
| 2064 | + grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| 2065 | + gap: 18px; | |
| 2066 | +} | |
| 2067 | + | |
| 2068 | +.challenge-card { | |
| 2069 | + border: 1px solid var(--line); | |
| 2070 | + border-top: 6px solid var(--blue); | |
| 2071 | + border-radius: 16px; | |
| 2072 | + background: white; | |
| 2073 | + transition: transform 160ms ease, box-shadow 160ms ease; | |
| 2074 | +} | |
| 2075 | + | |
| 2076 | +.challenge-card:hover { transform: translateY(-3px); box-shadow: var(--shadow); } | |
| 2077 | +.challenge-card-head { display: flex; align-items: center; justify-content: space-between; padding: 15px 18px 0; } | |
| 2078 | +.join-code { color: var(--muted); font-family: "IBM Plex Mono", monospace; font-size: 0.7rem; letter-spacing: 0.1em; } | |
| 2079 | +.challenge-card-body { display: flex; align-items: center; gap: 18px; padding: 18px; } | |
| 2080 | +.challenge-card-body h2 { margin: 0 0 5px; } | |
| 2081 | +.challenge-card-body p { margin: 0; color: var(--muted); font-size: 0.69rem; } | |
| 2082 | +.challenge-card footer { display: flex; align-items: center; justify-content: space-between; padding: 13px 18px; border-top: 1px dashed var(--line); color: var(--muted); font-size: 0.68rem; } | |
| 2083 | +.challenge-card footer span { display: flex; align-items: center; gap: 5px; } | |
| 2084 | + | |
| 2085 | +.challenge-hero { | |
| 2086 | + display: flex; | |
| 2087 | + align-items: center; | |
| 2088 | + justify-content: space-between; | |
| 2089 | + gap: 30px; | |
| 2090 | + padding: 30px 36px; | |
| 2091 | + border-radius: 19px; | |
| 2092 | + color: white; | |
| 2093 | +} | |
| 2094 | + | |
| 2095 | +.challenge-hero h2 { margin-bottom: 9px; color: white; font-size: 2.5rem; } | |
| 2096 | +.challenge-hero p { max-width: 650px; margin: 0; color: rgba(255, 255, 255, 0.8); font-size: 0.8rem; line-height: 1.6; } | |
| 2097 | + | |
| 2098 | +.challenge-layout { | |
| 2099 | + display: grid; | |
| 2100 | + grid-template-columns: 1fr 310px; | |
| 2101 | + gap: 20px; | |
| 2102 | + margin-top: 20px; | |
| 2103 | +} | |
| 2104 | + | |
| 2105 | +.challenge-side { display: flex; flex-direction: column; gap: 15px; } | |
| 2106 | +.challenge-dish-list > div { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 11px; padding: 13px 0; border-top: 1px solid #edf0f5; } | |
| 2107 | +.challenge-dish-list > div > span:nth-child(2) { display: flex; flex-direction: column; } | |
| 2108 | +.challenge-dish-list strong { font-size: 0.8rem; } | |
| 2109 | +.challenge-dish-list small, .challenge-dish-list > div > span:last-child { color: var(--muted); font-size: 0.65rem; } | |
| 2110 | +.challenge-dish-list .is-complete .check-mark { border-color: #7ac9b2; background: #d8f6ed; color: #12644f; } | |
| 2111 | +.challenge-dish-list .is-complete strong { text-decoration: line-through; } | |
| 2112 | +.invite-code { margin: 10px 0 6px; font-family: "IBM Plex Mono", monospace; font-size: 2rem; font-weight: 500; letter-spacing: 0.15em; } | |
| 2113 | +.invite-panel p { color: var(--muted); font-size: 0.75rem; } | |
| 2114 | +.participant-list { display: flex; flex-direction: column; } | |
| 2115 | +.participant-list > div { display: flex; align-items: center; gap: 9px; padding: 10px 0; border-top: 1px solid #edf0f5; } | |
| 2116 | +.participant-list > div > span { display: flex; flex-direction: column; } | |
| 2117 | +.participant-list strong { font-size: 0.77rem; } | |
| 2118 | +.participant-list small { color: var(--muted); font-size: 0.64rem; } | |
| 2119 | +.danger-link { display: inline-flex; align-items: center; align-self: flex-start; gap: 6px; padding: 7px 0; border: 0; background: transparent; font-size: 0.72rem; font-weight: 700; } | |
| 2120 | + | |
| 2121 | +/* Profile */ | |
| 2122 | +.profile-layout { | |
| 2123 | + display: grid; | |
| 2124 | + grid-template-columns: minmax(0, 1fr) 370px; | |
| 2125 | + gap: 22px; | |
| 2126 | +} | |
| 2127 | + | |
| 2128 | +.profile-identity { | |
| 2129 | + display: flex; | |
| 2130 | + align-items: center; | |
| 2131 | + gap: 16px; | |
| 2132 | + padding-bottom: 20px; | |
| 2133 | + border-bottom: 1px solid var(--line); | |
| 2134 | +} | |
| 2135 | + | |
| 2136 | +.profile-identity h2 { margin: 0 0 3px; } | |
| 2137 | +.profile-identity p { margin: 0; color: var(--muted); font-size: 0.74rem; } | |
| 2138 | +.privacy-toggle { display: flex; align-items: flex-start; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 11px; background: #f8fafe; cursor: pointer; } | |
| 2139 | +.privacy-toggle input { width: 19px; height: 19px; flex: 0 0 19px; accent-color: var(--blue); } | |
| 2140 | +.privacy-toggle > span { display: flex; flex-direction: column; margin: 0; } | |
| 2141 | +.privacy-toggle small { margin: 3px 0 0; font-weight: 400; } | |
| 2142 | +.profile-form > .button { align-self: flex-start; } | |
| 2143 | +.profile-side { display: flex; flex-direction: column; gap: 18px; } | |
| 2144 | +.share-panel > svg, .compare-start > svg { margin-bottom: 20px; color: var(--blue); } | |
| 2145 | +.share-panel h2, .compare-start h2 { margin-bottom: 9px; } | |
| 2146 | +.share-panel > p, .compare-start > p { color: var(--muted); font-size: 0.76rem; line-height: 1.55; } | |
| 2147 | +.share-panel.is-public { border-color: #90d3c0; background: #f0fbf7; } | |
| 2148 | +.share-url { display: flex; align-items: center; gap: 5px; margin: 17px 0 12px; padding: 8px 9px 8px 12px; border: 1px solid var(--line); border-radius: 9px; background: white; } | |
| 2149 | +.share-url span { min-width: 0; flex: 1; overflow: hidden; color: var(--muted); font-family: "IBM Plex Mono", monospace; font-size: 0.63rem; text-overflow: ellipsis; white-space: nowrap; } | |
| 2150 | +.share-url button { display: grid; width: 31px; height: 31px; place-items: center; border: 0; border-radius: 6px; background: var(--blue-pale); color: var(--blue); } | |
| 2151 | +.share-actions { display: flex; gap: 7px; } | |
| 2152 | +.compare-start form { display: flex; gap: 7px; margin-top: 15px; } | |
| 2153 | +.compare-start form input { min-width: 0; } | |
| 2154 | +.danger-panel { border-color: #efc7ca; } | |
| 2155 | +.danger-panel > svg { margin-bottom: 15px; color: #b9232e; } | |
| 2156 | +.danger-panel h2 { margin-bottom: 9px; } | |
| 2157 | +.danger-panel > p { color: var(--muted); font-size: 0.74rem; line-height: 1.55; } | |
| 2158 | +.danger-panel form { display: flex; flex-direction: column; gap: 10px; } | |
| 2159 | +.danger-panel form > div { display: flex; justify-content: flex-end; gap: 7px; } | |
| 2160 | + | |
| 2161 | +/* Public Tasteprint */ | |
| 2162 | +.public-page { min-height: 100vh; background: linear-gradient(180deg, #eef3ff 0, #f8faff 520px, var(--canvas) 100%); } | |
| 2163 | +.public-main { width: min(1040px, calc(100% - 40px)); margin: 20px auto 0; padding-bottom: 70px; } | |
| 2164 | +.public-passport { position: relative; min-height: 560px; padding: 36px; overflow: hidden; border-radius: 24px; background: var(--blue); color: white; box-shadow: 0 28px 70px rgba(33, 85, 245, 0.22); } | |
| 2165 | +.public-person { position: relative; z-index: 2; display: flex; align-items: center; gap: 17px; } | |
| 2166 | +.public-person h1 { margin: 0; color: white; font-size: clamp(2.6rem, 5vw, 4.4rem); } | |
| 2167 | +.public-person p { display: flex; align-items: center; gap: 5px; margin: 7px 0 0; color: #dce5ff; font-size: 0.72rem; } | |
| 2168 | +.public-bio { position: relative; z-index: 2; max-width: 570px; margin: 28px 0 0; color: #dce5ff; font-size: 0.86rem; line-height: 1.6; } | |
| 2169 | +.public-map { position: absolute; right: -25px; bottom: -15px; width: 75%; opacity: 0.9; } | |
| 2170 | +.public-stats { right: auto; bottom: 28px; left: 36px; } | |
| 2171 | +.public-section { margin-top: 50px; } | |
| 2172 | +.public-country-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 11px; } | |
| 2173 | +.public-country-list article { display: flex; align-items: center; gap: 10px; padding: 13px; border: 1px solid var(--line); border-radius: 12px; background: white; } | |
| 2174 | +.public-country-list article > span { display: grid; width: 42px; height: 42px; place-items: center; border-radius: 8px; color: white; font-family: "IBM Plex Mono", monospace; font-size: 0.67rem; } | |
| 2175 | +.public-country-list article > div { display: flex; min-width: 0; flex: 1; flex-direction: column; } | |
| 2176 | +.public-country-list strong { font-size: 0.78rem; } | |
| 2177 | +.public-country-list small { color: var(--muted); font-size: 0.62rem; } | |
| 2178 | +.public-country-list b { color: var(--blue); font-family: "Barlow Condensed", sans-serif; font-size: 1.25rem; } | |
| 2179 | +.public-tasting-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 15px; } | |
| 2180 | +.public-tasting-grid article { overflow: hidden; border: 1px solid var(--line); border-radius: 14px; background: white; } | |
| 2181 | +.public-tasting-photo { display: grid; height: 155px; place-items: center; color: white; } | |
| 2182 | +.public-tasting-photo img { width: 100%; height: 100%; object-fit: cover; } | |
| 2183 | +.public-tasting-grid article > div:last-child { padding: 15px; } | |
| 2184 | +.public-tasting-grid article span { color: var(--blue); font-family: "IBM Plex Mono", monospace; font-size: 0.59rem; text-transform: uppercase; } | |
| 2185 | +.public-tasting-grid h3 { margin: 6px 0 5px; } | |
| 2186 | +.public-tasting-grid p { margin: 0; color: var(--muted); font-size: 0.67rem; } | |
| 2187 | +.public-cta { display: flex; align-items: center; justify-content: space-between; gap: 25px; margin-top: 55px; padding: 32px 36px; border-radius: 18px; background: var(--ink); color: white; } | |
| 2188 | +.public-cta h2 { max-width: 650px; margin: 0; color: white; font-size: 2.3rem; } | |
| 2189 | +.public-state, .standalone-state { display: flex; min-height: 100vh; flex-direction: column; align-items: center; justify-content: center; gap: 18px; padding: 25px; } | |
| 2190 | + | |
| 2191 | +/* Comparison */ | |
| 2192 | +.match-card { display: grid; grid-template-columns: 1fr 190px 1fr; align-items: center; gap: 25px; padding: 28px; border-radius: 18px; background: var(--ink); color: white; } | |
| 2193 | +.match-person { display: flex; flex-direction: column; align-items: center; gap: 5px; text-align: center; } | |
| 2194 | +.match-person strong { margin-top: 6px; } | |
| 2195 | +.match-person > span { color: #9facc1; font-size: 0.67rem; } | |
| 2196 | +.match-score { display: flex; flex-direction: column; align-items: center; padding: 18px; border-right: 1px dashed #52617b; border-left: 1px dashed #52617b; text-align: center; } | |
| 2197 | +.match-score > span { color: var(--mango); font-family: "IBM Plex Mono", monospace; font-size: 0.63rem; text-transform: uppercase; } | |
| 2198 | +.match-score strong { font-family: "Barlow Condensed", sans-serif; font-size: 4rem; line-height: 1; } | |
| 2199 | +.match-score small { color: #aebbd0; font-size: 0.64rem; } | |
| 2200 | +.shared-bite { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 18px; margin-top: 20px; padding: 23px; border: 2px solid var(--mint); border-radius: 16px; background: white; } | |
| 2201 | +.shared-bite-icon { display: grid; width: 56px; height: 56px; place-items: center; border-radius: 50%; background: var(--blue-pale); color: var(--blue); } | |
| 2202 | +.shared-bite h2 { margin: 0 0 6px; } | |
| 2203 | +.shared-bite p { margin: 0 0 6px; color: var(--muted); font-size: 0.76rem; } | |
| 2204 | +.shared-bite small { display: flex; align-items: center; gap: 4px; color: var(--muted); font-size: 0.65rem; } | |
| 2205 | +.compare-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 17px; margin-top: 30px; } | |
| 2206 | +.compare-column { padding: 20px; border: 1px solid var(--line); border-radius: 14px; background: white; } | |
| 2207 | +.compare-column h3 { margin-bottom: 15px; } | |
| 2208 | +.compare-column > a { display: flex; align-items: center; gap: 9px; padding: 9px 0; border-top: 1px solid #edf0f5; } | |
| 2209 | +.compare-column > a > span { display: grid; width: 31px; height: 31px; place-items: center; border-radius: 6px; color: white; font-family: "IBM Plex Mono", monospace; font-size: 0.56rem; } | |
| 2210 | +.compare-column > a strong { flex: 1; font-size: 0.75rem; } | |
| 2211 | +.compare-column > p { color: var(--muted); font-size: 0.73rem; } | |
| 2212 | + | |
| 2213 | +.not-found-page { display: flex; min-height: 100vh; flex-direction: column; align-items: center; justify-content: center; padding: 30px; text-align: center; } | |
| 2214 | +.not-found-page h1 { max-width: 700px; } | |
| 2215 | + | |
| 2216 | +@media (max-width: 1050px) { | |
| 2217 | + .sidebar { width: 205px; } | |
| 2218 | + .app-main { margin-left: 205px; } | |
| 2219 | + .page { padding-right: 28px; padding-left: 28px; } | |
| 2220 | + .dashboard-grid, .profile-layout { grid-template-columns: 1fr; } | |
| 2221 | + .profile-side { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 2222 | + .destination-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 2223 | + .destination-hero { grid-template-columns: 100px 1fr 150px; } | |
| 2224 | + .destination-hero-code { font-size: 6rem; } | |
| 2225 | + .challenge-layout { grid-template-columns: 1fr 280px; } | |
| 2226 | +} | |
| 2227 | + | |
| 2228 | +@media (max-width: 820px) { | |
| 2229 | + .sidebar { display: none; } | |
| 2230 | + .app-main { margin: 64px 0 72px; } | |
| 2231 | + .mobile-header { position: fixed; z-index: 30; inset: 0 0 auto; display: flex; height: 64px; align-items: center; justify-content: space-between; padding: 10px 18px; border-bottom: 1px solid var(--line); background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(12px); } | |
| 2232 | + .mobile-nav { position: fixed; z-index: 30; inset: auto 0 0; display: grid; height: 68px; grid-template-columns: repeat(5, 1fr); border-top: 1px solid var(--line); background: rgba(255, 255, 255, 0.97); } | |
| 2233 | + .mobile-nav a { display: flex; min-width: 0; flex-direction: column; align-items: center; justify-content: center; gap: 3px; color: var(--muted); font-size: 0.56rem; } | |
| 2234 | + .mobile-nav a.active { color: var(--blue); } | |
| 2235 | + .page { padding: 32px 20px 50px; } | |
| 2236 | + .landing-hero { grid-template-columns: 1fr; padding-top: 40px; } | |
| 2237 | + .hero-ticket { min-height: 450px; } | |
| 2238 | + .landing-proof { grid-template-columns: 1fr; } | |
| 2239 | + .landing-proof article + article { border-top: 1px dashed #c5cede; border-left: 0; } | |
| 2240 | + .auth-page { grid-template-columns: 1fr; } | |
| 2241 | + .auth-story { display: none; } | |
| 2242 | + .auth-panel { min-height: 100vh; padding: 25px; } | |
| 2243 | + .dashboard-map { width: 74%; } | |
| 2244 | + .dish-grid-three { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 2245 | + .challenge-layout { grid-template-columns: 1fr; } | |
| 2246 | + .challenge-side { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 2247 | + .challenge-side .danger-link { grid-column: 1 / -1; } | |
| 2248 | + .public-country-list, .public-tasting-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| 2249 | +} | |
| 2250 | + | |
| 2251 | +@media (max-width: 620px) { | |
| 2252 | + .landing-nav, .public-nav { width: calc(100% - 30px); padding: 16px 0; } | |
| 2253 | + .landing-nav .button-ghost { display: none; } | |
| 2254 | + .landing-hero { width: calc(100% - 30px); min-height: auto; gap: 38px; padding: 48px 0 55px; } | |
| 2255 | + .hero-copy h1 { font-size: 3.7rem; } | |
| 2256 | + .hero-actions { align-items: flex-start; flex-direction: column; } | |
| 2257 | + .hero-ticket { min-height: 385px; padding: 22px; transform: none; } | |
| 2258 | + .hero-world-map { margin: -25px -40px -30px 10px; width: 120%; } | |
| 2259 | + .ticket-score { margin-top: 27px; } | |
| 2260 | + .ticket-score strong { font-size: 4rem; } | |
| 2261 | + .ticket-stamp { right: 25px; bottom: 25px; } | |
| 2262 | + .landing-proof { width: calc(100% - 30px); margin-bottom: 40px; } | |
| 2263 | + .page-header { align-items: flex-start; flex-direction: column; margin-bottom: 26px; } | |
| 2264 | + .page-header h1 { font-size: 2.8rem; } | |
| 2265 | + .page-header-action, .page-header-action .button { width: 100%; } | |
| 2266 | + .tasteprint-ticket { min-height: 520px; padding: 25px 24px; } | |
| 2267 | + .ticket-heading { flex-direction: column; } | |
| 2268 | + .ticket-heading h2 { font-size: 2.55rem; } | |
| 2269 | + .dashboard-map { right: -55px; bottom: 70px; width: 130%; } | |
| 2270 | + .ticket-stats { right: 22px; bottom: 25px; left: 22px; justify-content: space-between; gap: 8px; } | |
| 2271 | + .ticket-stats strong { font-size: 1.45rem; } | |
| 2272 | + .ticket-notch { display: none; } | |
| 2273 | + .dashboard-grid, .dish-grid-two, .dish-grid-three, .destination-grid, .trip-list, .challenge-grid, .profile-side, .public-country-list, .public-tasting-grid, .compare-grid { grid-template-columns: 1fr; } | |
| 2274 | + .recent-tastings { grid-template-columns: 1fr; } | |
| 2275 | + .section-heading { align-items: flex-start; flex-direction: column; } | |
| 2276 | + .section-note { align-items: flex-start; } | |
| 2277 | + .atlas-map-panel { min-height: 255px; padding: 45px 5px 10px; } | |
| 2278 | + .atlas-map-panel .world-map { width: 100%; } | |
| 2279 | + .atlas-map-copy { top: 12px; left: 13px; padding: 8px 10px; } | |
| 2280 | + .atlas-map-copy .eyebrow, .atlas-map-copy p { display: none; } | |
| 2281 | + .atlas-map-copy strong { font-size: 1.4rem; } | |
| 2282 | + .filter-bar, .journal-toolbar { align-items: stretch; flex-direction: column; } | |
| 2283 | + .search-field { width: 100%; } | |
| 2284 | + .filter-chips { justify-content: flex-start; } | |
| 2285 | + .destination-hero { display: flex; min-height: 500px; flex-direction: column; align-items: flex-start; justify-content: center; padding: 28px; } | |
| 2286 | + .destination-hero-code { position: absolute; top: 25px; right: 22px; font-size: 4rem; transform: none; } | |
| 2287 | + .destination-coverage-block { align-items: flex-start; } | |
| 2288 | + .culture-note { align-items: flex-start; } | |
| 2289 | + .journal-entry { grid-template-columns: 85px 1fr; gap: 12px; } | |
| 2290 | + .journal-image { min-height: 100px; } | |
| 2291 | + .journal-title-row { align-items: flex-start; } | |
| 2292 | + .journal-place { flex-wrap: wrap; } | |
| 2293 | + .journal-place time { width: 100%; margin-left: 20px; } | |
| 2294 | + .journal-actions { grid-column: 1 / -1; justify-content: flex-end; padding: 0; } | |
| 2295 | + .trip-detail-hero, .challenge-hero { align-items: flex-start; flex-direction: column; padding: 28px; } | |
| 2296 | + .trip-detail-score { align-items: flex-start; } | |
| 2297 | + .mission-sheet { padding: 22px 18px; } | |
| 2298 | + .mission-sheet > header { align-items: flex-start; flex-direction: column; } | |
| 2299 | + .mission-sheet li { grid-template-columns: 42px 1fr; gap: 12px; } | |
| 2300 | + .mission-sheet li > .button, .mission-done { grid-column: 2; justify-self: start; } | |
| 2301 | + .form-grid-two, .photo-location-row { grid-template-columns: 1fr; } | |
| 2302 | + .dialog-backdrop { padding: 0; place-items: end center; } | |
| 2303 | + .taste-dialog, .small-dialog { width: 100%; max-height: 94vh; border-radius: 18px 18px 0 0; } | |
| 2304 | + .dialog-actions .button { flex: 1; } | |
| 2305 | + .join-strip { grid-template-columns: 1fr; } | |
| 2306 | + .join-strip form { width: 100%; } | |
| 2307 | + .join-strip form input { flex: 1; width: auto; } | |
| 2308 | + .challenge-side { display: flex; } | |
| 2309 | + .profile-form { padding: 20px; } | |
| 2310 | + .profile-identity { align-items: flex-start; } | |
| 2311 | + .share-actions { flex-direction: column; } | |
| 2312 | + .public-main { width: calc(100% - 24px); } | |
| 2313 | + .public-passport { min-height: 650px; padding: 25px; } | |
| 2314 | + .public-person { align-items: flex-start; } | |
| 2315 | + .public-person h1 { font-size: 2.8rem; } | |
| 2316 | + .public-map { right: -50px; bottom: 90px; width: 135%; } | |
| 2317 | + .public-stats { right: 23px; bottom: 25px; left: 23px; } | |
| 2318 | + .public-cta { align-items: flex-start; flex-direction: column; padding: 27px; } | |
| 2319 | + .match-card { grid-template-columns: 1fr 1fr; } | |
| 2320 | + .match-score { grid-column: 1 / -1; grid-row: 2; border: 0; border-top: 1px dashed #52617b; } | |
| 2321 | + .shared-bite { grid-template-columns: auto 1fr; } | |
| 2322 | + .shared-bite .button { grid-column: 1 / -1; } | |
| 2323 | +} | |
| 2324 | + | |
| 2325 | +@media (prefers-reduced-motion: reduce) { | |
| 2326 | + *, *::before, *::after { | |
| 2327 | + scroll-behavior: auto !important; | |
| 2328 | + animation-duration: 0.01ms !important; | |
| 2329 | + animation-iteration-count: 1 !important; | |
| 2330 | + transition-duration: 0.01ms !important; | |
| 2331 | + } | |
| 2332 | +} |
added frontend/src/lib/api.test.ts +77 −0
| @@ -0,0 +1,77 @@ | ||
| 1 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 2 | +import { ApiError, api, clearSessionToken, getSessionToken, jsonBody, setSessionToken } from "./api"; | |
| 3 | + | |
| 4 | +describe("API client", () => { | |
| 5 | + beforeEach(() => { | |
| 6 | + localStorage.clear(); | |
| 7 | + }); | |
| 8 | + | |
| 9 | + afterEach(() => { | |
| 10 | + vi.unstubAllGlobals(); | |
| 11 | + }); | |
| 12 | + | |
| 13 | + it("adds the bearer token and JSON headers", async () => { | |
| 14 | + setSessionToken("secret-session"); | |
| 15 | + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { | |
| 16 | + status: 200, | |
| 17 | + headers: { "Content-Type": "application/json" } | |
| 18 | + })); | |
| 19 | + vi.stubGlobal("fetch", fetchMock); | |
| 20 | + | |
| 21 | + await expect(api<{ ok: boolean }>("/api/test", { | |
| 22 | + method: "POST", | |
| 23 | + ...jsonBody({ name: "Ramen" }) | |
| 24 | + })).resolves.toEqual({ ok: true }); | |
| 25 | + | |
| 26 | + const [, request] = fetchMock.mock.calls[0] as [string, RequestInit]; | |
| 27 | + const headers = request.headers as Headers; | |
| 28 | + expect(headers.get("Authorization")).toBe("Bearer secret-session"); | |
| 29 | + expect(headers.get("Content-Type")).toBe("application/json"); | |
| 30 | + expect(request.body).toBe('{"name":"Ramen"}'); | |
| 31 | + }); | |
| 32 | + | |
| 33 | + it("preserves form data without adding a JSON content type", async () => { | |
| 34 | + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ url: "/uploads/a.png" }), { | |
| 35 | + status: 201, | |
| 36 | + headers: { "Content-Type": "application/json" } | |
| 37 | + })); | |
| 38 | + vi.stubGlobal("fetch", fetchMock); | |
| 39 | + const form = new FormData(); | |
| 40 | + form.append("file", new Blob(["image"]), "meal.png"); | |
| 41 | + | |
| 42 | + await api("/api/v1/media", { method: "POST", body: form }); | |
| 43 | + | |
| 44 | + const [, request] = fetchMock.mock.calls[0] as [string, RequestInit]; | |
| 45 | + expect((request.headers as Headers).has("Content-Type")).toBe(false); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + it("turns problem details into an ApiError", async () => { | |
| 49 | + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify({ | |
| 50 | + title: "Validation failed", | |
| 51 | + detail: "Check the highlighted fields.", | |
| 52 | + errors: { city: "must not be blank" } | |
| 53 | + }), { status: 400, headers: { "Content-Type": "application/problem+json" } }))); | |
| 54 | + | |
| 55 | + const result = api("/api/test"); | |
| 56 | + await expect(result).rejects.toMatchObject({ | |
| 57 | + name: "ApiError", | |
| 58 | + status: 400, | |
| 59 | + message: "Check the highlighted fields.", | |
| 60 | + errors: { city: "must not be blank" } | |
| 61 | + } satisfies Partial<ApiError>); | |
| 62 | + }); | |
| 63 | + | |
| 64 | + it("clears an expired session after a 401 response", async () => { | |
| 65 | + setSessionToken("expired"); | |
| 66 | + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("not-json", { status: 401 }))); | |
| 67 | + | |
| 68 | + await expect(api("/api/private")).rejects.toBeInstanceOf(ApiError); | |
| 69 | + expect(getSessionToken()).toBeNull(); | |
| 70 | + }); | |
| 71 | + | |
| 72 | + it("handles an empty success response", async () => { | |
| 73 | + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 204 }))); | |
| 74 | + await expect(api<void>("/api/delete", { method: "DELETE" })).resolves.toBeUndefined(); | |
| 75 | + clearSessionToken(); | |
| 76 | + }); | |
| 77 | +}); |
added frontend/src/lib/api.ts +63 −0
| @@ -0,0 +1,63 @@ | ||
| 1 | +import type { ApiProblem } from "../types"; | |
| 2 | + | |
| 3 | +const API_BASE = import.meta.env.VITE_API_URL ?? ""; | |
| 4 | +const TOKEN_KEY = "tasteprint.session"; | |
| 5 | + | |
| 6 | +export class ApiError extends Error { | |
| 7 | + status: number; | |
| 8 | + errors: Record<string, string>; | |
| 9 | + | |
| 10 | + constructor(status: number, problem: ApiProblem) { | |
| 11 | + super(problem.detail || problem.title || "The request could not be completed."); | |
| 12 | + this.name = "ApiError"; | |
| 13 | + this.status = status; | |
| 14 | + this.errors = problem.errors ?? {}; | |
| 15 | + } | |
| 16 | +} | |
| 17 | + | |
| 18 | +export function getSessionToken(): string | null { | |
| 19 | + return localStorage.getItem(TOKEN_KEY); | |
| 20 | +} | |
| 21 | + | |
| 22 | +export function setSessionToken(token: string): void { | |
| 23 | + localStorage.setItem(TOKEN_KEY, token); | |
| 24 | +} | |
| 25 | + | |
| 26 | +export function clearSessionToken(): void { | |
| 27 | + localStorage.removeItem(TOKEN_KEY); | |
| 28 | +} | |
| 29 | + | |
| 30 | +export async function api<T>(path: string, init: RequestInit = {}): Promise<T> { | |
| 31 | + const headers = new Headers(init.headers); | |
| 32 | + const token = getSessionToken(); | |
| 33 | + if (token) { | |
| 34 | + headers.set("Authorization", `Bearer ${token}`); | |
| 35 | + } | |
| 36 | + if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) { | |
| 37 | + headers.set("Content-Type", "application/json"); | |
| 38 | + } | |
| 39 | + headers.set("Accept", "application/json"); | |
| 40 | + | |
| 41 | + const response = await fetch(`${API_BASE}${path}`, { ...init, headers }); | |
| 42 | + if (!response.ok) { | |
| 43 | + let problem: ApiProblem; | |
| 44 | + try { | |
| 45 | + problem = await response.json() as ApiProblem; | |
| 46 | + } catch { | |
| 47 | + problem = { detail: `Request failed with status ${response.status}.` }; | |
| 48 | + } | |
| 49 | + if (response.status === 401) { | |
| 50 | + clearSessionToken(); | |
| 51 | + window.dispatchEvent(new Event("tasteprint:unauthorized")); | |
| 52 | + } | |
| 53 | + throw new ApiError(response.status, problem); | |
| 54 | + } | |
| 55 | + if (response.status === 204) { | |
| 56 | + return undefined as T; | |
| 57 | + } | |
| 58 | + return response.json() as Promise<T>; | |
| 59 | +} | |
| 60 | + | |
| 61 | +export function jsonBody(value: unknown): Pick<RequestInit, "body"> { | |
| 62 | + return { body: JSON.stringify(value) }; | |
| 63 | +} |
added frontend/src/lib/auth.tsx +105 −0
| @@ -0,0 +1,105 @@ | ||
| 1 | +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; | |
| 2 | +import { useQueryClient } from "@tanstack/react-query"; | |
| 3 | +import { api, clearSessionToken, getSessionToken, jsonBody, setSessionToken } from "./api"; | |
| 4 | +import type { Account, AuthSession } from "../types"; | |
| 5 | + | |
| 6 | +interface AuthContextValue { | |
| 7 | + user: Account | null; | |
| 8 | + loading: boolean; | |
| 9 | + login: (email: string, password: string) => Promise<void>; | |
| 10 | + register: (displayName: string, email: string, password: string) => Promise<void>; | |
| 11 | + logout: () => Promise<void>; | |
| 12 | + deleteAccount: (password: string) => Promise<void>; | |
| 13 | + refresh: () => Promise<void>; | |
| 14 | +} | |
| 15 | + | |
| 16 | +const AuthContext = createContext<AuthContextValue | null>(null); | |
| 17 | + | |
| 18 | +export function AuthProvider({ children }: { children: ReactNode }) { | |
| 19 | + const queryClient = useQueryClient(); | |
| 20 | + const [user, setUser] = useState<Account | null>(null); | |
| 21 | + const [loading, setLoading] = useState(true); | |
| 22 | + | |
| 23 | + const refresh = useCallback(async () => { | |
| 24 | + if (!getSessionToken()) { | |
| 25 | + setUser(null); | |
| 26 | + setLoading(false); | |
| 27 | + return; | |
| 28 | + } | |
| 29 | + try { | |
| 30 | + setUser(await api<Account>("/api/v1/auth/me")); | |
| 31 | + } catch { | |
| 32 | + clearSessionToken(); | |
| 33 | + queryClient.clear(); | |
| 34 | + setUser(null); | |
| 35 | + } finally { | |
| 36 | + setLoading(false); | |
| 37 | + } | |
| 38 | + }, [queryClient]); | |
| 39 | + | |
| 40 | + useEffect(() => { | |
| 41 | + void refresh(); | |
| 42 | + }, [refresh]); | |
| 43 | + | |
| 44 | + useEffect(() => { | |
| 45 | + function handleUnauthorized() { | |
| 46 | + queryClient.clear(); | |
| 47 | + setUser(null); | |
| 48 | + } | |
| 49 | + window.addEventListener("tasteprint:unauthorized", handleUnauthorized); | |
| 50 | + return () => window.removeEventListener("tasteprint:unauthorized", handleUnauthorized); | |
| 51 | + }, [queryClient]); | |
| 52 | + | |
| 53 | + const login = useCallback(async (email: string, password: string) => { | |
| 54 | + const session = await api<AuthSession>("/api/v1/auth/login", { | |
| 55 | + method: "POST", | |
| 56 | + ...jsonBody({ email, password }) | |
| 57 | + }); | |
| 58 | + queryClient.clear(); | |
| 59 | + setSessionToken(session.token); | |
| 60 | + setUser(session.user); | |
| 61 | + }, [queryClient]); | |
| 62 | + | |
| 63 | + const register = useCallback(async (displayName: string, email: string, password: string) => { | |
| 64 | + const session = await api<AuthSession>("/api/v1/auth/register", { | |
| 65 | + method: "POST", | |
| 66 | + ...jsonBody({ displayName, email, password }) | |
| 67 | + }); | |
| 68 | + queryClient.clear(); | |
| 69 | + setSessionToken(session.token); | |
| 70 | + setUser(session.user); | |
| 71 | + }, [queryClient]); | |
| 72 | + | |
| 73 | + const logout = useCallback(async () => { | |
| 74 | + try { | |
| 75 | + await api<void>("/api/v1/auth/logout", { method: "POST" }); | |
| 76 | + } finally { | |
| 77 | + clearSessionToken(); | |
| 78 | + queryClient.clear(); | |
| 79 | + setUser(null); | |
| 80 | + } | |
| 81 | + }, [queryClient]); | |
| 82 | + | |
| 83 | + const deleteAccount = useCallback(async (password: string) => { | |
| 84 | + await api<void>("/api/v1/auth/me", { | |
| 85 | + method: "DELETE", | |
| 86 | + ...jsonBody({ password }) | |
| 87 | + }); | |
| 88 | + clearSessionToken(); | |
| 89 | + queryClient.clear(); | |
| 90 | + setUser(null); | |
| 91 | + }, [queryClient]); | |
| 92 | + | |
| 93 | + const value = useMemo(() => ({ user, loading, login, register, logout, deleteAccount, refresh }), | |
| 94 | + [user, loading, login, register, logout, deleteAccount, refresh]); | |
| 95 | + | |
| 96 | + return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; | |
| 97 | +} | |
| 98 | + | |
| 99 | +export function useAuth(): AuthContextValue { | |
| 100 | + const context = useContext(AuthContext); | |
| 101 | + if (!context) { | |
| 102 | + throw new Error("useAuth must be used inside AuthProvider"); | |
| 103 | + } | |
| 104 | + return context; | |
| 105 | +} |
added frontend/src/lib/format.test.ts +22 −0
| @@ -0,0 +1,22 @@ | ||
| 1 | +import { describe, expect, it, vi } from "vitest"; | |
| 2 | +import { formatDate, formatDateRange, initials, todayInput } from "./format"; | |
| 3 | + | |
| 4 | +describe("format helpers", () => { | |
| 5 | + it("formats dates and ranges without timezone drift", () => { | |
| 6 | + expect(formatDate("2026-08-09")).toContain("9 Aug 2026"); | |
| 7 | + expect(formatDateRange("2026-08-09", "2026-08-09")).toContain("9 Aug 2026"); | |
| 8 | + expect(formatDateRange("2026-08-09", "2026-08-12")).toBe("9 Aug to 12 Aug"); | |
| 9 | + }); | |
| 10 | + | |
| 11 | + it("builds two-letter initials", () => { | |
| 12 | + expect(initials("Rasmus Jürimäe")).toBe("RJ"); | |
| 13 | + expect(initials("Maria")).toBe("M"); | |
| 14 | + }); | |
| 15 | + | |
| 16 | + it("returns the local calendar day for date inputs", () => { | |
| 17 | + vi.useFakeTimers(); | |
| 18 | + vi.setSystemTime(new Date("2026-08-09T22:30:00+03:00")); | |
| 19 | + expect(todayInput()).toBe("2026-08-09"); | |
| 20 | + vi.useRealTimers(); | |
| 21 | + }); | |
| 22 | +}); |
added frontend/src/lib/format.ts +22 −0
| @@ -0,0 +1,22 @@ | ||
| 1 | +export function formatDate(value: string): string { | |
| 2 | + return new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric" }) | |
| 3 | + .format(new Date(`${value}T12:00:00`)); | |
| 4 | +} | |
| 5 | + | |
| 6 | +export function formatDateRange(start: string, end: string): string { | |
| 7 | + if (start === end) { | |
| 8 | + return formatDate(start); | |
| 9 | + } | |
| 10 | + const formatter = new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short" }); | |
| 11 | + return `${formatter.format(new Date(`${start}T12:00:00`))} to ${formatter.format(new Date(`${end}T12:00:00`))}`; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function initials(name: string): string { | |
| 15 | + return name.split(/\s+/).slice(0, 2).map(part => part[0]?.toUpperCase()).join(""); | |
| 16 | +} | |
| 17 | + | |
| 18 | +export function todayInput(): string { | |
| 19 | + const now = new Date(); | |
| 20 | + const offset = now.getTimezoneOffset(); | |
| 21 | + return new Date(now.getTime() - offset * 60_000).toISOString().slice(0, 10); | |
| 22 | +} |
added frontend/src/lib/useModalDialog.test.tsx +49 −0
| @@ -0,0 +1,49 @@ | ||
| 1 | +import { useState } from "react"; | |
| 2 | +import { render, screen, waitFor } from "@testing-library/react"; | |
| 3 | +import userEvent from "@testing-library/user-event"; | |
| 4 | +import { expect, it, vi } from "vitest"; | |
| 5 | +import { useModalDialog } from "./useModalDialog"; | |
| 6 | + | |
| 7 | +function TestDialog({ onClose }: { onClose: () => void }) { | |
| 8 | + const dialogRef = useModalDialog(onClose); | |
| 9 | + return ( | |
| 10 | + <section ref={dialogRef} role="dialog" aria-label="Test dialog"> | |
| 11 | + <button type="button" data-initial-focus>First</button> | |
| 12 | + <button type="button">Last</button> | |
| 13 | + </section> | |
| 14 | + ); | |
| 15 | +} | |
| 16 | + | |
| 17 | +function Harness({ onClose }: { onClose: () => void }) { | |
| 18 | + const [open, setOpen] = useState(false); | |
| 19 | + return ( | |
| 20 | + <> | |
| 21 | + <button type="button" onClick={() => setOpen(true)}>Open</button> | |
| 22 | + {open && <TestDialog onClose={() => { onClose(); setOpen(false); }} />} | |
| 23 | + </> | |
| 24 | + ); | |
| 25 | +} | |
| 26 | + | |
| 27 | +it("traps focus, closes with Escape, and restores the previous focus", async () => { | |
| 28 | + const user = userEvent.setup(); | |
| 29 | + const onClose = vi.fn(); | |
| 30 | + render(<Harness onClose={onClose} />); | |
| 31 | + | |
| 32 | + const opener = screen.getByRole("button", { name: "Open" }); | |
| 33 | + await user.click(opener); | |
| 34 | + const first = screen.getByRole("button", { name: "First" }); | |
| 35 | + const last = screen.getByRole("button", { name: "Last" }); | |
| 36 | + await waitFor(() => expect(first).toHaveFocus()); | |
| 37 | + expect(document.body.style.overflow).toBe("hidden"); | |
| 38 | + | |
| 39 | + await user.tab({ shift: true }); | |
| 40 | + expect(last).toHaveFocus(); | |
| 41 | + await user.tab(); | |
| 42 | + expect(first).toHaveFocus(); | |
| 43 | + | |
| 44 | + await user.keyboard("{Escape}"); | |
| 45 | + expect(onClose).toHaveBeenCalledOnce(); | |
| 46 | + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); | |
| 47 | + expect(opener).toHaveFocus(); | |
| 48 | + expect(document.body.style.overflow).toBe(""); | |
| 49 | +}); |
added frontend/src/lib/useModalDialog.ts +48 −0
| @@ -0,0 +1,48 @@ | ||
| 1 | +import { useEffect, useRef } from "react"; | |
| 2 | + | |
| 3 | +const FOCUSABLE = "button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex='-1'])"; | |
| 4 | + | |
| 5 | +export function useModalDialog(onClose: () => void) { | |
| 6 | + const dialogRef = useRef<HTMLElement>(null); | |
| 7 | + const onCloseRef = useRef(onClose); | |
| 8 | + | |
| 9 | + useEffect(() => { | |
| 10 | + onCloseRef.current = onClose; | |
| 11 | + }, [onClose]); | |
| 12 | + | |
| 13 | + useEffect(() => { | |
| 14 | + const previousOverflow = document.body.style.overflow; | |
| 15 | + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; | |
| 16 | + document.body.style.overflow = "hidden"; | |
| 17 | + dialogRef.current?.querySelector<HTMLElement>("[data-initial-focus], " + FOCUSABLE)?.focus(); | |
| 18 | + | |
| 19 | + function handleKeyDown(event: KeyboardEvent) { | |
| 20 | + if (event.key === "Escape") { | |
| 21 | + event.preventDefault(); | |
| 22 | + onCloseRef.current(); | |
| 23 | + return; | |
| 24 | + } | |
| 25 | + if (event.key !== "Tab" || !dialogRef.current) return; | |
| 26 | + const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)); | |
| 27 | + if (!focusable.length) return; | |
| 28 | + const first = focusable[0]; | |
| 29 | + const last = focusable[focusable.length - 1]; | |
| 30 | + if (event.shiftKey && document.activeElement === first) { | |
| 31 | + event.preventDefault(); | |
| 32 | + last.focus(); | |
| 33 | + } else if (!event.shiftKey && document.activeElement === last) { | |
| 34 | + event.preventDefault(); | |
| 35 | + first.focus(); | |
| 36 | + } | |
| 37 | + } | |
| 38 | + | |
| 39 | + document.addEventListener("keydown", handleKeyDown); | |
| 40 | + return () => { | |
| 41 | + document.removeEventListener("keydown", handleKeyDown); | |
| 42 | + document.body.style.overflow = previousOverflow; | |
| 43 | + previousFocus?.focus(); | |
| 44 | + }; | |
| 45 | + }, []); | |
| 46 | + | |
| 47 | + return dialogRef; | |
| 48 | +} |
added frontend/src/main.tsx +30 −0
| @@ -0,0 +1,30 @@ | ||
| 1 | +import { StrictMode } from "react"; | |
| 2 | +import { createRoot } from "react-dom/client"; | |
| 3 | +import { BrowserRouter } from "react-router-dom"; | |
| 4 | +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; | |
| 5 | +import "@fontsource/barlow-condensed/600.css"; | |
| 6 | +import "@fontsource/barlow-condensed/700.css"; | |
| 7 | +import "@fontsource/manrope/400.css"; | |
| 8 | +import "@fontsource/manrope/500.css"; | |
| 9 | +import "@fontsource/manrope/600.css"; | |
| 10 | +import "@fontsource/manrope/700.css"; | |
| 11 | +import "@fontsource/ibm-plex-mono/500.css"; | |
| 12 | +import "./index.css"; | |
| 13 | +import { App } from "./App"; | |
| 14 | +import { AuthProvider } from "./lib/auth"; | |
| 15 | + | |
| 16 | +const queryClient = new QueryClient({ | |
| 17 | + defaultOptions: { | |
| 18 | + queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false } | |
| 19 | + } | |
| 20 | +}); | |
| 21 | + | |
| 22 | +createRoot(document.getElementById("root")!).render( | |
| 23 | + <StrictMode> | |
| 24 | + <QueryClientProvider client={queryClient}> | |
| 25 | + <BrowserRouter> | |
| 26 | + <AuthProvider><App /></AuthProvider> | |
| 27 | + </BrowserRouter> | |
| 28 | + </QueryClientProvider> | |
| 29 | + </StrictMode> | |
| 30 | +); |
added frontend/src/pages/AuthPage.tsx +121 −0
| @@ -0,0 +1,121 @@ | ||
| 1 | +import { useState, type FormEvent } from "react"; | |
| 2 | +import { ArrowLeft, ArrowRight, LoaderCircle, Stamp } from "lucide-react"; | |
| 3 | +import { Link, Navigate, useLocation, useNavigate, useSearchParams } from "react-router-dom"; | |
| 4 | +import { Logo } from "../components/Logo"; | |
| 5 | +import { useAuth } from "../lib/auth"; | |
| 6 | +import { ApiError } from "../lib/api"; | |
| 7 | + | |
| 8 | +export function AuthPage({ mode }: { mode: "login" | "register" }) { | |
| 9 | + const { user, loading, login, register } = useAuth(); | |
| 10 | + const [params] = useSearchParams(); | |
| 11 | + const navigate = useNavigate(); | |
| 12 | + const location = useLocation(); | |
| 13 | + const [displayName, setDisplayName] = useState(""); | |
| 14 | + const [email, setEmail] = useState(params.get("demo") === "true" ? "demo@tasteprint.app" : ""); | |
| 15 | + const [password, setPassword] = useState(params.get("demo") === "true" ? "tasteprint" : ""); | |
| 16 | + const [submitting, setSubmitting] = useState(false); | |
| 17 | + const [error, setError] = useState(""); | |
| 18 | + | |
| 19 | + if (!loading && user) { | |
| 20 | + return <Navigate to={params.get("next") || "/app"} replace />; | |
| 21 | + } | |
| 22 | + | |
| 23 | + async function submit(event: FormEvent) { | |
| 24 | + event.preventDefault(); | |
| 25 | + setSubmitting(true); | |
| 26 | + setError(""); | |
| 27 | + try { | |
| 28 | + if (mode === "register") { | |
| 29 | + await register(displayName, email, password); | |
| 30 | + } else { | |
| 31 | + await login(email, password); | |
| 32 | + } | |
| 33 | + navigate(params.get("next") || "/app", { replace: true }); | |
| 34 | + } catch (caught) { | |
| 35 | + setError(caught instanceof ApiError ? caught.message : "Sign in could not be completed."); | |
| 36 | + } finally { | |
| 37 | + setSubmitting(false); | |
| 38 | + } | |
| 39 | + } | |
| 40 | + | |
| 41 | + async function openDemo() { | |
| 42 | + setEmail("demo@tasteprint.app"); | |
| 43 | + setPassword("tasteprint"); | |
| 44 | + setSubmitting(true); | |
| 45 | + setError(""); | |
| 46 | + try { | |
| 47 | + await login("demo@tasteprint.app", "tasteprint"); | |
| 48 | + navigate(params.get("next") || "/app", { replace: true }); | |
| 49 | + } catch (caught) { | |
| 50 | + setError(caught instanceof ApiError ? caught.message : "The demo could not be opened."); | |
| 51 | + } finally { | |
| 52 | + setSubmitting(false); | |
| 53 | + } | |
| 54 | + } | |
| 55 | + | |
| 56 | + return ( | |
| 57 | + <main className="auth-page"> | |
| 58 | + <section className="auth-story"> | |
| 59 | + <Logo /> | |
| 60 | + <div> | |
| 61 | + <span className="eyebrow">Your edible travel record</span> | |
| 62 | + <h1>Every destination leaves a different mark.</h1> | |
| 63 | + <div className="auth-stamps" aria-hidden="true"> | |
| 64 | + <span className="auth-stamp auth-stamp-jp">JP<small>44%</small></span> | |
| 65 | + <span className="auth-stamp auth-stamp-pt">PT<small>68%</small></span> | |
| 66 | + <span className="auth-stamp auth-stamp-mx">MX<small>35%</small></span> | |
| 67 | + </div> | |
| 68 | + </div> | |
| 69 | + <p>Built for travellers who remember a city by what was on the table.</p> | |
| 70 | + </section> | |
| 71 | + | |
| 72 | + <section className="auth-panel"> | |
| 73 | + <Link className="back-link" to="/"><ArrowLeft size={16} /> Back</Link> | |
| 74 | + <div className="auth-form-wrap"> | |
| 75 | + <span className="auth-seal"><Stamp size={24} /></span> | |
| 76 | + <span className="eyebrow">{mode === "login" ? "Welcome back" : "Issue your passport"}</span> | |
| 77 | + <h2>{mode === "login" ? "Continue your food map" : "Start your Tasteprint"}</h2> | |
| 78 | + | |
| 79 | + <form className="auth-form" onSubmit={submit}> | |
| 80 | + {mode === "register" && ( | |
| 81 | + <label> | |
| 82 | + <span>Name</span> | |
| 83 | + <input value={displayName} onChange={event => setDisplayName(event.target.value)} | |
| 84 | + autoComplete="name" maxLength={80} required /> | |
| 85 | + </label> | |
| 86 | + )} | |
| 87 | + <label> | |
| 88 | + <span>Email</span> | |
| 89 | + <input type="email" value={email} onChange={event => setEmail(event.target.value)} | |
| 90 | + autoComplete="email" required /> | |
| 91 | + </label> | |
| 92 | + <label> | |
| 93 | + <span>Password</span> | |
| 94 | + <input type="password" value={password} onChange={event => setPassword(event.target.value)} | |
| 95 | + autoComplete={mode === "login" ? "current-password" : "new-password"} | |
| 96 | + minLength={8} maxLength={72} required /> | |
| 97 | + </label> | |
| 98 | + {error && <p className="form-error" role="alert">{error}</p>} | |
| 99 | + <button className="button button-primary button-full" type="submit" disabled={submitting}> | |
| 100 | + {submitting ? <LoaderCircle className="spin" size={18} /> : <ArrowRight size={18} />} | |
| 101 | + {mode === "login" ? "Sign in" : "Create passport"} | |
| 102 | + </button> | |
| 103 | + </form> | |
| 104 | + | |
| 105 | + {mode === "login" && ( | |
| 106 | + <button className="button button-outline button-full" type="button" onClick={() => void openDemo()} disabled={submitting}> | |
| 107 | + Open demo passport | |
| 108 | + </button> | |
| 109 | + )} | |
| 110 | + | |
| 111 | + <p className="auth-switch"> | |
| 112 | + {mode === "login" ? "New here?" : "Already have a passport?"}{" "} | |
| 113 | + <Link to={mode === "login" ? "/register" : "/login"} state={{ from: location }}> | |
| 114 | + {mode === "login" ? "Create an account" : "Sign in"} | |
| 115 | + </Link> | |
| 116 | + </p> | |
| 117 | + </div> | |
| 118 | + </section> | |
| 119 | + </main> | |
| 120 | + ); | |
| 121 | +} |
added frontend/src/pages/ChallengeDetailsPage.tsx +107 −0
| @@ -0,0 +1,107 @@ | ||
| 1 | +import { useState } from "react"; | |
| 2 | +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | +import { Check, LogOut, Share2, Trash2 } from "lucide-react"; | |
| 4 | +import { useNavigate, useParams } from "react-router-dom"; | |
| 5 | +import { ProgressRing } from "../components/ProgressRing"; | |
| 6 | +import { Avatar, ErrorState, LoadingScreen, PageHeader, StatusPill } from "../components/Ui"; | |
| 7 | +import { useAuth } from "../lib/auth"; | |
| 8 | +import { api } from "../lib/api"; | |
| 9 | +import { formatDateRange } from "../lib/format"; | |
| 10 | +import type { Challenge } from "../types"; | |
| 11 | + | |
| 12 | +export function ChallengeDetailsPage() { | |
| 13 | + const { challengeId } = useParams(); | |
| 14 | + const { user } = useAuth(); | |
| 15 | + const navigate = useNavigate(); | |
| 16 | + const queryClient = useQueryClient(); | |
| 17 | + const [copied, setCopied] = useState(false); | |
| 18 | + const challenge = useQuery({ | |
| 19 | + queryKey: ["challenge", challengeId], | |
| 20 | + queryFn: () => api<Challenge>(`/api/v1/challenges/${challengeId}`), | |
| 21 | + enabled: Boolean(challengeId) | |
| 22 | + }); | |
| 23 | + const remove = useMutation({ | |
| 24 | + mutationFn: () => api<void>(user?.id === challenge.data?.ownerId | |
| 25 | + ? `/api/v1/challenges/${challengeId}` | |
| 26 | + : `/api/v1/challenges/${challengeId}/members/me`, { method: "DELETE" }), | |
| 27 | + onSuccess: async () => { | |
| 28 | + await queryClient.invalidateQueries({ queryKey: ["challenges"] }); | |
| 29 | + navigate("/app/challenges"); | |
| 30 | + } | |
| 31 | + }); | |
| 32 | + | |
| 33 | + if (challenge.isLoading) return <LoadingScreen label="Opening challenge" />; | |
| 34 | + if (challenge.isError || !challenge.data) return <ErrorState message="This challenge could not be opened." />; | |
| 35 | + const data = challenge.data; | |
| 36 | + const isOwner = user?.id === data.ownerId; | |
| 37 | + const completed = data.dishes.filter(item => item.tried).length; | |
| 38 | + | |
| 39 | + async function copyInvite() { | |
| 40 | + await navigator.clipboard.writeText(`${window.location.origin}/join/${data.joinCode}`); | |
| 41 | + setCopied(true); | |
| 42 | + window.setTimeout(() => setCopied(false), 2000); | |
| 43 | + } | |
| 44 | + | |
| 45 | + function confirmRemove() { | |
| 46 | + const message = isOwner ? "Delete this challenge for everyone?" : "Leave this challenge?"; | |
| 47 | + if (window.confirm(message)) remove.mutate(); | |
| 48 | + } | |
| 49 | + | |
| 50 | + return ( | |
| 51 | + <div className="page challenge-details-page"> | |
| 52 | + <PageHeader eyebrow={`${data.destination.name} challenge`} title={data.title} | |
| 53 | + copy={formatDateRange(data.startsOn, data.endsOn)} action={<StatusPill status={data.status} />} /> | |
| 54 | + | |
| 55 | + <section className="challenge-hero" style={{ backgroundColor: data.destination.accentColor }}> | |
| 56 | + <div> | |
| 57 | + <span className="eyebrow eyebrow-light">Shared culinary coverage</span> | |
| 58 | + <h2>{completed} of {data.dishes.length} core dishes found</h2> | |
| 59 | + <p>Every participant can add to this score by logging a dish during the challenge dates.</p> | |
| 60 | + </div> | |
| 61 | + <ProgressRing value={data.groupCoverage} size="large" label="group coverage" light /> | |
| 62 | + </section> | |
| 63 | + | |
| 64 | + <div className="challenge-layout"> | |
| 65 | + <section className="panel"> | |
| 66 | + <div className="section-heading"> | |
| 67 | + <div><span className="eyebrow">Group checklist</span><h2>What the table has covered</h2></div> | |
| 68 | + </div> | |
| 69 | + <div className="challenge-dish-list"> | |
| 70 | + {data.dishes.map(item => ( | |
| 71 | + <div key={item.dish.slug} className={item.tried ? "is-complete" : ""}> | |
| 72 | + <span className="check-mark">{item.tried && <Check size={15} />}</span> | |
| 73 | + <span><strong>{item.dish.name}</strong><small>{item.dish.categoryLabel}</small></span> | |
| 74 | + <span>{item.dish.importance === 3 ? "Essential" : item.dish.importance === 2 ? "Core" : "Discovery"}</span> | |
| 75 | + </div> | |
| 76 | + ))} | |
| 77 | + </div> | |
| 78 | + </section> | |
| 79 | + | |
| 80 | + <aside className="challenge-side"> | |
| 81 | + <section className="panel invite-panel"> | |
| 82 | + <span className="eyebrow">Invite people</span> | |
| 83 | + <div className="invite-code">{data.joinCode}</div> | |
| 84 | + <p>Share the code or a direct join link.</p> | |
| 85 | + <button className="button button-outline button-full" type="button" onClick={() => void copyInvite()}> | |
| 86 | + {copied ? <Check size={17} /> : <Share2 size={17} />} {copied ? "Link copied" : "Copy invite link"} | |
| 87 | + </button> | |
| 88 | + </section> | |
| 89 | + <section className="panel participant-panel"> | |
| 90 | + <div className="section-heading"><div><span className="eyebrow">Travelers</span><h2>{data.participants.length} contributing</h2></div></div> | |
| 91 | + <div className="participant-list"> | |
| 92 | + {data.participants.map(participant => ( | |
| 93 | + <div key={participant.user.id}> | |
| 94 | + <Avatar name={participant.user.displayName} url={participant.user.avatarUrl} size="small" /> | |
| 95 | + <span><strong>{participant.user.displayName}</strong><small>{participant.contributedDishes} dishes added</small></span> | |
| 96 | + </div> | |
| 97 | + ))} | |
| 98 | + </div> | |
| 99 | + </section> | |
| 100 | + <button className="danger-link" type="button" onClick={confirmRemove} disabled={remove.isPending}> | |
| 101 | + {isOwner ? <Trash2 size={16} /> : <LogOut size={16} />}{isOwner ? "Delete challenge" : "Leave challenge"} | |
| 102 | + </button> | |
| 103 | + </aside> | |
| 104 | + </div> | |
| 105 | + </div> | |
| 106 | + ); | |
| 107 | +} |
added frontend/src/pages/ChallengesPage.tsx +94 −0
| @@ -0,0 +1,94 @@ | ||
| 1 | +import { useState, type FormEvent } from "react"; | |
| 2 | +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | +import { ArrowRight, KeyRound, Plus, UsersRound } from "lucide-react"; | |
| 4 | +import { Link, useNavigate } from "react-router-dom"; | |
| 5 | +import { ChallengeFormDialog } from "../components/ChallengeFormDialog"; | |
| 6 | +import { ProgressRing } from "../components/ProgressRing"; | |
| 7 | +import { EmptyState, ErrorState, LoadingScreen, PageHeader, StatusPill } from "../components/Ui"; | |
| 8 | +import { ApiError, api, jsonBody } from "../lib/api"; | |
| 9 | +import { formatDateRange } from "../lib/format"; | |
| 10 | +import type { Challenge } from "../types"; | |
| 11 | + | |
| 12 | +export function ChallengesPage() { | |
| 13 | + const [creating, setCreating] = useState(false); | |
| 14 | + const [joinCode, setJoinCode] = useState(""); | |
| 15 | + const [joinError, setJoinError] = useState(""); | |
| 16 | + const navigate = useNavigate(); | |
| 17 | + const queryClient = useQueryClient(); | |
| 18 | + const challenges = useQuery({ | |
| 19 | + queryKey: ["challenges"], | |
| 20 | + queryFn: () => api<Challenge[]>("/api/v1/challenges") | |
| 21 | + }); | |
| 22 | + const join = useMutation({ | |
| 23 | + mutationFn: () => api<Challenge>("/api/v1/challenges/join", { | |
| 24 | + method: "POST", | |
| 25 | + ...jsonBody({ joinCode: joinCode.trim().toUpperCase() }) | |
| 26 | + }), | |
| 27 | + onSuccess: async challenge => { | |
| 28 | + await queryClient.invalidateQueries({ queryKey: ["challenges"] }); | |
| 29 | + navigate(`/app/challenges/${challenge.id}`); | |
| 30 | + }, | |
| 31 | + onError: caught => setJoinError(caught instanceof ApiError ? caught.message : "The challenge could not be joined.") | |
| 32 | + }); | |
| 33 | + | |
| 34 | + function submitJoin(event: FormEvent) { | |
| 35 | + event.preventDefault(); | |
| 36 | + setJoinError(""); | |
| 37 | + join.mutate(); | |
| 38 | + } | |
| 39 | + | |
| 40 | + if (challenges.isLoading) return <LoadingScreen label="Opening shared maps" />; | |
| 41 | + if (challenges.isError) return <ErrorState message="Your challenges could not be loaded." />; | |
| 42 | + | |
| 43 | + return ( | |
| 44 | + <div className="page challenges-page"> | |
| 45 | + <PageHeader | |
| 46 | + eyebrow="Taste together" | |
| 47 | + title="One destination. Everyone brings a bite." | |
| 48 | + copy="Create a food challenge for a trip, a flat, or a group of friends. Every real tasting moves the same map." | |
| 49 | + action={<button className="button button-primary" type="button" onClick={() => setCreating(true)}><Plus size={18} /> New challenge</button>} | |
| 50 | + /> | |
| 51 | + | |
| 52 | + <section className="join-strip"> | |
| 53 | + <div><KeyRound size={21} /><span><strong>Have an invite code?</strong><small>Codes contain six letters or numbers.</small></span></div> | |
| 54 | + <form onSubmit={submitJoin}> | |
| 55 | + <input aria-label="Challenge code" value={joinCode} onChange={event => setJoinCode(event.target.value.toUpperCase())} | |
| 56 | + placeholder="ABC123" maxLength={6} pattern="[A-Z0-9]{6}" required /> | |
| 57 | + <button className="button button-ink" type="submit" disabled={join.isPending}>Join</button> | |
| 58 | + </form> | |
| 59 | + {joinError && <p className="form-error" role="alert">{joinError}</p>} | |
| 60 | + </section> | |
| 61 | + | |
| 62 | + {challenges.data?.length ? ( | |
| 63 | + <section className="challenge-grid" aria-label="Your challenges"> | |
| 64 | + {challenges.data.map(challenge => ( | |
| 65 | + <Link className="challenge-card" key={challenge.id} to={`/app/challenges/${challenge.id}`} | |
| 66 | + style={{ borderTopColor: challenge.destination.accentColor }}> | |
| 67 | + <div className="challenge-card-head"> | |
| 68 | + <StatusPill status={challenge.status} /> | |
| 69 | + <span className="join-code">{challenge.joinCode}</span> | |
| 70 | + </div> | |
| 71 | + <div className="challenge-card-body"> | |
| 72 | + <ProgressRing value={challenge.groupCoverage} size="medium" label="group coverage" /> | |
| 73 | + <div> | |
| 74 | + <span className="eyebrow">{challenge.destination.name}</span> | |
| 75 | + <h2>{challenge.title}</h2> | |
| 76 | + <p>{formatDateRange(challenge.startsOn, challenge.endsOn)}</p> | |
| 77 | + </div> | |
| 78 | + </div> | |
| 79 | + <footer> | |
| 80 | + <span><UsersRound size={15} /> {challenge.participants.length} {challenge.participants.length === 1 ? "traveler" : "travelers"}</span> | |
| 81 | + <ArrowRight size={17} /> | |
| 82 | + </footer> | |
| 83 | + </Link> | |
| 84 | + ))} | |
| 85 | + </section> | |
| 86 | + ) : ( | |
| 87 | + <EmptyState title="No shared maps yet" copy="Create one for your next trip or join a friend with their code." | |
| 88 | + action={<button className="button button-primary" onClick={() => setCreating(true)}>Create a challenge</button>} /> | |
| 89 | + )} | |
| 90 | + | |
| 91 | + {creating && <ChallengeFormDialog onClose={() => setCreating(false)} onCreated={challenge => navigate(`/app/challenges/${challenge.id}`)} />} | |
| 92 | + </div> | |
| 93 | + ); | |
| 94 | +} |
added frontend/src/pages/ComparePage.tsx +65 −0
| @@ -0,0 +1,65 @@ | ||
| 1 | +import { useQuery } from "@tanstack/react-query"; | |
| 2 | +import { ArrowLeft, ArrowRight, MapPin, UtensilsCrossed } from "lucide-react"; | |
| 3 | +import { Link, useParams } from "react-router-dom"; | |
| 4 | +import { Avatar, ErrorState, LoadingScreen, PageHeader } from "../components/Ui"; | |
| 5 | +import { useAuth } from "../lib/auth"; | |
| 6 | +import { api } from "../lib/api"; | |
| 7 | +import type { Destination, TasteComparison } from "../types"; | |
| 8 | + | |
| 9 | +function CountryList({ title, countries, empty }: { title: string; countries: Destination[]; empty: string }) { | |
| 10 | + return ( | |
| 11 | + <section className="compare-column"> | |
| 12 | + <h3>{title}</h3> | |
| 13 | + {countries.length ? countries.map(country => ( | |
| 14 | + <Link key={country.code} to={`/app/destinations/${country.code}`}> | |
| 15 | + <span style={{ backgroundColor: country.accentColor }}>{country.code}</span> | |
| 16 | + <strong>{country.name}</strong> | |
| 17 | + <ArrowRight size={15} /> | |
| 18 | + </Link> | |
| 19 | + )) : <p>{empty}</p>} | |
| 20 | + </section> | |
| 21 | + ); | |
| 22 | +} | |
| 23 | + | |
| 24 | +export function ComparePage() { | |
| 25 | + const { shareSlug } = useParams(); | |
| 26 | + const { user } = useAuth(); | |
| 27 | + const comparison = useQuery({ | |
| 28 | + queryKey: ["comparison", shareSlug], | |
| 29 | + queryFn: () => api<TasteComparison>(`/api/v1/social/compare/${shareSlug}`), | |
| 30 | + enabled: Boolean(shareSlug), | |
| 31 | + retry: false | |
| 32 | + }); | |
| 33 | + | |
| 34 | + if (comparison.isLoading) return <LoadingScreen label="Laying two food maps together" />; | |
| 35 | + if (comparison.isError || !comparison.data) return <ErrorState message="This Tasteprint could not be compared. It may be private." action={<Link className="button button-outline" to="/app/profile">Try another</Link>} />; | |
| 36 | + const data = comparison.data; | |
| 37 | + | |
| 38 | + return ( | |
| 39 | + <div className="page compare-page"> | |
| 40 | + <Link className="back-link" to="/app/profile"><ArrowLeft size={16} /> Profile</Link> | |
| 41 | + <PageHeader eyebrow="Taste map match" title="What belongs on your shared table?" | |
| 42 | + copy="This compares dishes, not ratings. Different tastes can still make a very good meal." /> | |
| 43 | + | |
| 44 | + <section className="match-card"> | |
| 45 | + <div className="match-person"><Avatar name={user?.displayName ?? "You"} url={user?.avatarUrl} size="large" /><strong>{user?.displayName}</strong><span>Your map</span></div> | |
| 46 | + <div className="match-score"><span>Taste overlap</span><strong>{data.overlapScore}%</strong><small>{data.sharedDishes} dishes in common</small></div> | |
| 47 | + <div className="match-person"><Avatar name={data.otherUser.displayName} url={data.otherUser.avatarUrl} size="large" /><strong>{data.otherUser.displayName}</strong><span>Their map</span></div> | |
| 48 | + </section> | |
| 49 | + | |
| 50 | + {data.suggestedSharedBite && ( | |
| 51 | + <section className="shared-bite" style={{ borderColor: data.suggestedSharedBite.destinationAccent }}> | |
| 52 | + <div className="shared-bite-icon"><UtensilsCrossed size={24} /></div> | |
| 53 | + <div><span className="eyebrow">Your next shared bite</span><h2>{data.suggestedSharedBite.name}</h2><p>{data.suggestedSharedBite.description}</p><small><MapPin size={14} /> {data.suggestedSharedBite.destinationName}, {data.suggestedSharedBite.categoryLabel}</small></div> | |
| 54 | + <Link className="button button-primary" to={`/app/destinations/${data.suggestedSharedBite.destinationCode}`}>See the food culture</Link> | |
| 55 | + </section> | |
| 56 | + )} | |
| 57 | + | |
| 58 | + <div className="compare-grid"> | |
| 59 | + <CountryList title="You both tasted" countries={data.sharedCountries} empty="No shared countries yet." /> | |
| 60 | + <CountryList title="Only on your map" countries={data.yourUniqueCountries} empty="Nothing unique yet." /> | |
| 61 | + <CountryList title={`Only on ${data.otherUser.displayName.split(" ")[0]}'s map`} countries={data.theirUniqueCountries} empty="Nothing unique yet." /> | |
| 62 | + </div> | |
| 63 | + </div> | |
| 64 | + ); | |
| 65 | +} |
added frontend/src/pages/DashboardPage.tsx +174 −0
| @@ -0,0 +1,174 @@ | ||
| 1 | +import { useQuery } from "@tanstack/react-query"; | |
| 2 | +import { ArrowRight, CalendarDays, Check, MapPin, Plus, Share2, Sparkles } from "lucide-react"; | |
| 3 | +import { Link } from "react-router-dom"; | |
| 4 | +import { DishCard } from "../components/DishCard"; | |
| 5 | +import { ProgressRing } from "../components/ProgressRing"; | |
| 6 | +import { useQuickTaste } from "../components/QuickTaste"; | |
| 7 | +import { ErrorState, LoadingScreen, PageHeader, StatusPill } from "../components/Ui"; | |
| 8 | +import { WorldMap } from "../components/WorldMap"; | |
| 9 | +import { api } from "../lib/api"; | |
| 10 | +import { formatDate, formatDateRange } from "../lib/format"; | |
| 11 | +import type { Dashboard } from "../types"; | |
| 12 | + | |
| 13 | +export function DashboardPage() { | |
| 14 | + const { openTaste } = useQuickTaste(); | |
| 15 | + const dashboard = useQuery({ | |
| 16 | + queryKey: ["dashboard"], | |
| 17 | + queryFn: () => api<Dashboard>("/api/v1/progress/dashboard") | |
| 18 | + }); | |
| 19 | + | |
| 20 | + if (dashboard.isLoading) return <LoadingScreen />; | |
| 21 | + if (dashboard.isError || !dashboard.data) { | |
| 22 | + return <ErrorState message="Your Tasteprint could not be loaded." action={ | |
| 23 | + <button className="button button-outline" onClick={() => void dashboard.refetch()}>Try again</button> | |
| 24 | + } />; | |
| 25 | + } | |
| 26 | + | |
| 27 | + const data = dashboard.data; | |
| 28 | + const explored = data.tasteprint.destinations | |
| 29 | + .filter(item => item.coverage > 0) | |
| 30 | + .sort((a, b) => b.coverage - a.coverage) | |
| 31 | + .slice(0, 4); | |
| 32 | + | |
| 33 | + return ( | |
| 34 | + <div className="page dashboard-page"> | |
| 35 | + <PageHeader | |
| 36 | + eyebrow="Personal food map" | |
| 37 | + title={`Good to see you, ${data.user.displayName.split(" ")[0]}.`} | |
| 38 | + copy="Your next meaningful bite is easier to see from here." | |
| 39 | + action={ | |
| 40 | + <button className="button button-primary" type="button" onClick={() => openTaste()}> | |
| 41 | + <Plus size={18} /> Log a taste | |
| 42 | + </button> | |
| 43 | + } | |
| 44 | + /> | |
| 45 | + | |
| 46 | + <section className="tasteprint-ticket"> | |
| 47 | + <div className="ticket-heading"> | |
| 48 | + <div> | |
| 49 | + <span className="ticket-code">TP / {data.user.shareSlug.toUpperCase()}</span> | |
| 50 | + <span className="eyebrow eyebrow-light">World culinary coverage</span> | |
| 51 | + <h2>{data.tasteprint.stats.worldCoverage}% of the current atlas tasted</h2> | |
| 52 | + </div> | |
| 53 | + <Link className="ticket-share" to="/app/profile"><Share2 size={16} /> Share Tasteprint</Link> | |
| 54 | + </div> | |
| 55 | + <WorldMap destinations={data.tasteprint.destinations} className="dashboard-map" /> | |
| 56 | + <div className="ticket-stats"> | |
| 57 | + <div><strong>{data.tasteprint.stats.uniqueDishes}</strong><span>Dishes</span></div> | |
| 58 | + <div><strong>{data.tasteprint.stats.countriesTasted}</strong><span>Countries</span></div> | |
| 59 | + <div><strong>{data.tasteprint.stats.regionsTasted}</strong><span>Regions</span></div> | |
| 60 | + <div><strong>{data.tasteprint.stats.totalTastings}</strong><span>Memories</span></div> | |
| 61 | + </div> | |
| 62 | + <div className="ticket-notch ticket-notch-top" /> | |
| 63 | + <div className="ticket-notch ticket-notch-bottom" /> | |
| 64 | + </section> | |
| 65 | + | |
| 66 | + <div className="dashboard-grid"> | |
| 67 | + <section className="panel next-trip-panel"> | |
| 68 | + <div className="section-heading"> | |
| 69 | + <div> | |
| 70 | + <span className="eyebrow">Next food mission</span> | |
| 71 | + <h2>{data.nextTrip ? `${data.nextTrip.city}, ${data.nextTrip.destination.name}` : "No trip planned"}</h2> | |
| 72 | + </div> | |
| 73 | + {data.nextTrip && <StatusPill status={data.nextTrip.status} />} | |
| 74 | + </div> | |
| 75 | + {data.nextTrip ? ( | |
| 76 | + <> | |
| 77 | + <div className="trip-meta-row"> | |
| 78 | + <span><CalendarDays size={15} /> {formatDateRange(data.nextTrip.startsOn, data.nextTrip.endsOn)}</span> | |
| 79 | + <span>{data.nextTrip.missionCompleted}/{data.nextTrip.mission.length} bites completed</span> | |
| 80 | + </div> | |
| 81 | + <ol className="mission-list"> | |
| 82 | + {data.nextTrip.mission.map(item => ( | |
| 83 | + <li key={item.dish.slug} className={item.completed ? "mission-complete" : ""}> | |
| 84 | + <span className="mission-number">{item.completed ? <Check size={14} /> : item.position}</span> | |
| 85 | + <span> | |
| 86 | + <strong>{item.dish.name}</strong> | |
| 87 | + <small>{item.dish.categoryLabel}</small> | |
| 88 | + </span> | |
| 89 | + {!item.completed && ( | |
| 90 | + <button type="button" onClick={() => openTaste({ dish: item.dish })}>Log</button> | |
| 91 | + )} | |
| 92 | + </li> | |
| 93 | + ))} | |
| 94 | + </ol> | |
| 95 | + <Link className="panel-link" to={`/app/trips/${data.nextTrip.id}`}> | |
| 96 | + Open trip mission <ArrowRight size={16} /> | |
| 97 | + </Link> | |
| 98 | + </> | |
| 99 | + ) : ( | |
| 100 | + <div className="compact-empty"> | |
| 101 | + <MapPin size={22} /> | |
| 102 | + <p>Add a destination and Tasteprint will select five culturally different bites.</p> | |
| 103 | + <Link className="button button-outline" to="/app/trips">Plan a trip</Link> | |
| 104 | + </div> | |
| 105 | + )} | |
| 106 | + </section> | |
| 107 | + | |
| 108 | + <section className="panel explored-panel"> | |
| 109 | + <div className="section-heading"> | |
| 110 | + <div> | |
| 111 | + <span className="eyebrow">Your strongest routes</span> | |
| 112 | + <h2>Food cultures started</h2> | |
| 113 | + </div> | |
| 114 | + <Link className="small-link" to="/app/explore">All countries</Link> | |
| 115 | + </div> | |
| 116 | + <div className="coverage-list"> | |
| 117 | + {explored.map(item => ( | |
| 118 | + <Link key={item.destination.code} to={`/app/destinations/${item.destination.code}`}> | |
| 119 | + <ProgressRing value={item.coverage} size="small" /> | |
| 120 | + <span> | |
| 121 | + <strong>{item.destination.name}</strong> | |
| 122 | + <small>{item.triedDishes}/{item.totalDishes} core dishes</small> | |
| 123 | + </span> | |
| 124 | + <ArrowRight size={16} /> | |
| 125 | + </Link> | |
| 126 | + ))} | |
| 127 | + </div> | |
| 128 | + {!explored.length && ( | |
| 129 | + <div className="compact-empty"><p>Your first dish will start this list.</p></div> | |
| 130 | + )} | |
| 131 | + </section> | |
| 132 | + </div> | |
| 133 | + | |
| 134 | + <section className="dashboard-section"> | |
| 135 | + <div className="section-heading"> | |
| 136 | + <div> | |
| 137 | + <span className="eyebrow">Important gaps</span> | |
| 138 | + <h2>Do not leave these untasted</h2> | |
| 139 | + </div> | |
| 140 | + <span className="section-note"><Sparkles size={15} /> Based on your map and next trip</span> | |
| 141 | + </div> | |
| 142 | + <div className="dish-grid dish-grid-three"> | |
| 143 | + {data.tasteprint.importantMissing.slice(0, 3).map(dish => ( | |
| 144 | + <DishCard key={dish.slug} dish={dish} compact /> | |
| 145 | + ))} | |
| 146 | + </div> | |
| 147 | + </section> | |
| 148 | + | |
| 149 | + <section className="dashboard-section recent-section"> | |
| 150 | + <div className="section-heading"> | |
| 151 | + <div> | |
| 152 | + <span className="eyebrow">Recent passport marks</span> | |
| 153 | + <h2>Meals worth remembering</h2> | |
| 154 | + </div> | |
| 155 | + <Link className="small-link" to="/app/tastings">Full journal</Link> | |
| 156 | + </div> | |
| 157 | + <div className="recent-tastings"> | |
| 158 | + {data.tasteprint.recentTastings.slice(0, 4).map(tasting => ( | |
| 159 | + <article key={tasting.id}> | |
| 160 | + <div className="recent-photo" style={{ backgroundColor: tasting.dish.destinationAccent }}> | |
| 161 | + {tasting.photoUrl ? <img src={tasting.photoUrl} alt="" /> : <span>{tasting.dish.destinationCode}</span>} | |
| 162 | + </div> | |
| 163 | + <div> | |
| 164 | + <strong>{tasting.dish.name}</strong> | |
| 165 | + <span><MapPin size={13} /> {tasting.city}</span> | |
| 166 | + </div> | |
| 167 | + <time dateTime={tasting.tastedOn}>{formatDate(tasting.tastedOn)}</time> | |
| 168 | + </article> | |
| 169 | + ))} | |
| 170 | + </div> | |
| 171 | + </section> | |
| 172 | + </div> | |
| 173 | + ); | |
| 174 | +} |
added frontend/src/pages/DestinationPage.tsx +73 −0
| @@ -0,0 +1,73 @@ | ||
| 1 | +import type { CSSProperties } from "react"; | |
| 2 | +import { useQuery } from "@tanstack/react-query"; | |
| 3 | +import { ArrowLeft, Check, Info, Plus } from "lucide-react"; | |
| 4 | +import { Link, useParams } from "react-router-dom"; | |
| 5 | +import { DishCard } from "../components/DishCard"; | |
| 6 | +import { ProgressRing } from "../components/ProgressRing"; | |
| 7 | +import { useQuickTaste } from "../components/QuickTaste"; | |
| 8 | +import { ErrorState, LoadingScreen } from "../components/Ui"; | |
| 9 | +import { api } from "../lib/api"; | |
| 10 | +import type { DestinationProgressDetails } from "../types"; | |
| 11 | + | |
| 12 | +export function DestinationPage() { | |
| 13 | + const { code = "" } = useParams(); | |
| 14 | + const { openTaste } = useQuickTaste(); | |
| 15 | + const progress = useQuery({ | |
| 16 | + queryKey: ["destination-progress", code], | |
| 17 | + queryFn: () => api<DestinationProgressDetails>(`/api/v1/progress/destinations/${code}`), | |
| 18 | + enabled: Boolean(code) | |
| 19 | + }); | |
| 20 | + | |
| 21 | + if (progress.isLoading) return <LoadingScreen label="Opening this food culture" />; | |
| 22 | + if (progress.isError || !progress.data) return <ErrorState message="This destination could not be loaded." />; | |
| 23 | + | |
| 24 | + const data = progress.data; | |
| 25 | + const nextDish = data.dishes.find(item => !item.tried)?.dish; | |
| 26 | + | |
| 27 | + return ( | |
| 28 | + <div className="page destination-page"> | |
| 29 | + <Link className="back-link" to="/app/explore"><ArrowLeft size={16} /> Food atlas</Link> | |
| 30 | + | |
| 31 | + <section className="destination-hero" style={{ "--destination-accent": data.progress.destination.accentColor } as CSSProperties}> | |
| 32 | + <div className="destination-hero-code">{data.progress.destination.code}</div> | |
| 33 | + <div className="destination-hero-main"> | |
| 34 | + <span className="eyebrow eyebrow-light">{data.progress.destination.regionName}</span> | |
| 35 | + <h1>{data.progress.destination.name}</h1> | |
| 36 | + <p className="destination-local-name">{data.progress.destination.localName}</p> | |
| 37 | + <p>{data.progress.destination.summary}</p> | |
| 38 | + {nextDish && ( | |
| 39 | + <button className="button button-paper" type="button" onClick={() => openTaste({ dish: nextDish })}> | |
| 40 | + <Plus size={17} /> Log your next {data.progress.destination.name} dish | |
| 41 | + </button> | |
| 42 | + )} | |
| 43 | + </div> | |
| 44 | + <div className="destination-coverage-block"> | |
| 45 | + <ProgressRing value={data.progress.coverage} size="large" label={`${data.progress.destination.name} coverage`} /> | |
| 46 | + <span>{data.progress.triedDishes}/{data.progress.totalDishes} core experiences</span> | |
| 47 | + </div> | |
| 48 | + <div className="destination-stamp-outline" aria-hidden="true" /> | |
| 49 | + </section> | |
| 50 | + | |
| 51 | + <section className="culture-note"> | |
| 52 | + <Info size={20} /> | |
| 53 | + <div> | |
| 54 | + <strong>Coverage means breadth, not authority.</strong> | |
| 55 | + <p>The six starting points cover daily food, street eating, mornings, sweets, drinks, and a defining dish. No short list can represent every regional kitchen.</p> | |
| 56 | + </div> | |
| 57 | + </section> | |
| 58 | + | |
| 59 | + <section className="destination-dishes"> | |
| 60 | + <div className="section-heading"> | |
| 61 | + <div> | |
| 62 | + <span className="eyebrow">Culinary core</span> | |
| 63 | + <h2>Six ways into the culture</h2> | |
| 64 | + </div> | |
| 65 | + <span className="section-note"><Check size={15} /> Tried dishes count wherever you tasted them</span> | |
| 66 | + </div> | |
| 67 | + <div className="dish-grid dish-grid-two"> | |
| 68 | + {data.dishes.map(item => <DishCard key={item.dish.slug} dish={item.dish} tried={item.tried} />)} | |
| 69 | + </div> | |
| 70 | + </section> | |
| 71 | + </div> | |
| 72 | + ); | |
| 73 | +} |
added frontend/src/pages/ExplorePage.tsx +91 −0
| @@ -0,0 +1,91 @@ | ||
| 1 | +import { useMemo, useState } from "react"; | |
| 2 | +import { useQuery } from "@tanstack/react-query"; | |
| 3 | +import { ArrowRight, Search } from "lucide-react"; | |
| 4 | +import { Link } from "react-router-dom"; | |
| 5 | +import { ProgressRing } from "../components/ProgressRing"; | |
| 6 | +import { ErrorState, LoadingScreen, PageHeader } from "../components/Ui"; | |
| 7 | +import { WorldMap } from "../components/WorldMap"; | |
| 8 | +import { api } from "../lib/api"; | |
| 9 | +import type { Dashboard } from "../types"; | |
| 10 | + | |
| 11 | +export function ExplorePage() { | |
| 12 | + const [query, setQuery] = useState(""); | |
| 13 | + const [region, setRegion] = useState("ALL"); | |
| 14 | + const dashboard = useQuery({ | |
| 15 | + queryKey: ["dashboard"], | |
| 16 | + queryFn: () => api<Dashboard>("/api/v1/progress/dashboard") | |
| 17 | + }); | |
| 18 | + | |
| 19 | + const regions = useMemo(() => Array.from(new Set( | |
| 20 | + dashboard.data?.tasteprint.destinations.map(item => item.destination.regionName) ?? [] | |
| 21 | + )).sort(), [dashboard.data]); | |
| 22 | + | |
| 23 | + const filtered = useMemo(() => dashboard.data?.tasteprint.destinations.filter(item => { | |
| 24 | + const search = query.trim().toLowerCase(); | |
| 25 | + const matchesQuery = !search | |
| 26 | + || item.destination.name.toLowerCase().includes(search) | |
| 27 | + || item.destination.localName.toLowerCase().includes(search) | |
| 28 | + || item.destination.regionName.toLowerCase().includes(search); | |
| 29 | + return matchesQuery && (region === "ALL" || item.destination.regionName === region); | |
| 30 | + }) ?? [], [dashboard.data, query, region]); | |
| 31 | + | |
| 32 | + if (dashboard.isLoading) return <LoadingScreen label="Opening the food atlas" />; | |
| 33 | + if (dashboard.isError || !dashboard.data) return <ErrorState message="The food atlas could not be loaded." />; | |
| 34 | + | |
| 35 | + return ( | |
| 36 | + <div className="page explore-page"> | |
| 37 | + <PageHeader | |
| 38 | + eyebrow="The current atlas" | |
| 39 | + title="Taste the world by culture, not by checklist." | |
| 40 | + copy="Each destination starts with six different parts of its food life. The catalog grows through local review, not popularity alone." | |
| 41 | + /> | |
| 42 | + | |
| 43 | + <section className="atlas-map-panel"> | |
| 44 | + <WorldMap destinations={dashboard.data.tasteprint.destinations} /> | |
| 45 | + <div className="atlas-map-copy"> | |
| 46 | + <span className="eyebrow">Your map</span> | |
| 47 | + <strong>{dashboard.data.tasteprint.stats.countriesTasted} of {dashboard.data.tasteprint.destinations.length}</strong> | |
| 48 | + <p>food cultures started</p> | |
| 49 | + </div> | |
| 50 | + </section> | |
| 51 | + | |
| 52 | + <div className="filter-bar"> | |
| 53 | + <label className="search-field"> | |
| 54 | + <Search size={17} /> | |
| 55 | + <span className="sr-only">Search countries</span> | |
| 56 | + <input value={query} onChange={event => setQuery(event.target.value)} placeholder="Search country or region" /> | |
| 57 | + </label> | |
| 58 | + <div className="filter-chips" aria-label="Filter by region"> | |
| 59 | + <button className={region === "ALL" ? "active" : ""} onClick={() => setRegion("ALL")}>All</button> | |
| 60 | + {regions.map(item => ( | |
| 61 | + <button key={item} className={region === item ? "active" : ""} onClick={() => setRegion(item)}>{item}</button> | |
| 62 | + ))} | |
| 63 | + </div> | |
| 64 | + </div> | |
| 65 | + | |
| 66 | + <section className="destination-grid" aria-label="Food cultures"> | |
| 67 | + {filtered.map(item => ( | |
| 68 | + <Link className="destination-card" key={item.destination.code} to={`/app/destinations/${item.destination.code}`}> | |
| 69 | + <div className="destination-card-top" style={{ backgroundColor: item.destination.accentColor }}> | |
| 70 | + <span className="destination-code">{item.destination.code}</span> | |
| 71 | + <span className="destination-local">{item.destination.localName}</span> | |
| 72 | + <div className="destination-contours" aria-hidden="true" /> | |
| 73 | + </div> | |
| 74 | + <div className="destination-card-body"> | |
| 75 | + <div> | |
| 76 | + <span className="eyebrow">{item.destination.regionName}</span> | |
| 77 | + <h2>{item.destination.name}</h2> | |
| 78 | + </div> | |
| 79 | + <ProgressRing value={item.coverage} size="small" /> | |
| 80 | + <p>{item.destination.summary}</p> | |
| 81 | + <footer> | |
| 82 | + <span>{item.triedDishes}/{item.totalDishes} core dishes</span> | |
| 83 | + <ArrowRight size={17} /> | |
| 84 | + </footer> | |
| 85 | + </div> | |
| 86 | + </Link> | |
| 87 | + ))} | |
| 88 | + </section> | |
| 89 | + </div> | |
| 90 | + ); | |
| 91 | +} |
added frontend/src/pages/JoinChallengePage.tsx +23 −0
| @@ -0,0 +1,23 @@ | ||
| 1 | +import { useEffect, useRef, useState } from "react"; | |
| 2 | +import { useNavigate, useParams } from "react-router-dom"; | |
| 3 | +import { api, ApiError, jsonBody } from "../lib/api"; | |
| 4 | +import type { Challenge } from "../types"; | |
| 5 | +import { ErrorState, LoadingScreen } from "../components/Ui"; | |
| 6 | + | |
| 7 | +export function JoinChallengePage() { | |
| 8 | + const { code } = useParams(); | |
| 9 | + const navigate = useNavigate(); | |
| 10 | + const started = useRef(false); | |
| 11 | + const [error, setError] = useState(""); | |
| 12 | + | |
| 13 | + useEffect(() => { | |
| 14 | + if (!code || started.current) return; | |
| 15 | + started.current = true; | |
| 16 | + api<Challenge>("/api/v1/challenges/join", { method: "POST", ...jsonBody({ joinCode: code }) }) | |
| 17 | + .then(challenge => navigate(`/app/challenges/${challenge.id}`, { replace: true })) | |
| 18 | + .catch(caught => setError(caught instanceof ApiError ? caught.message : "The invitation could not be opened.")); | |
| 19 | + }, [code, navigate]); | |
| 20 | + | |
| 21 | + if (error) return <div className="standalone-state"><ErrorState message={error} /></div>; | |
| 22 | + return <div className="standalone-state"><LoadingScreen label="Joining the shared map" /></div>; | |
| 23 | +} |
added frontend/src/pages/LandingPage.tsx +100 −0
| @@ -0,0 +1,100 @@ | ||
| 1 | +import { useQuery } from "@tanstack/react-query"; | |
| 2 | +import { ArrowRight, Camera, Check, MapPinned, Plane } from "lucide-react"; | |
| 3 | +import { Link, Navigate } from "react-router-dom"; | |
| 4 | +import { Logo } from "../components/Logo"; | |
| 5 | +import { WorldMap } from "../components/WorldMap"; | |
| 6 | +import { useAuth } from "../lib/auth"; | |
| 7 | +import { api } from "../lib/api"; | |
| 8 | +import type { Destination, DestinationProgress } from "../types"; | |
| 9 | + | |
| 10 | +const sampleCoverage: Record<string, number> = { | |
| 11 | + EE: 82, JP: 44, MX: 35, PT: 68, GE: 19, VN: 52 | |
| 12 | +}; | |
| 13 | + | |
| 14 | +export function LandingPage() { | |
| 15 | + const { user, loading } = useAuth(); | |
| 16 | + const destinations = useQuery({ | |
| 17 | + queryKey: ["destinations"], | |
| 18 | + queryFn: () => api<Destination[]>("/api/v1/catalog/destinations") | |
| 19 | + }); | |
| 20 | + | |
| 21 | + if (!loading && user) { | |
| 22 | + return <Navigate to="/app" replace />; | |
| 23 | + } | |
| 24 | + | |
| 25 | + const mapData: DestinationProgress[] = (destinations.data ?? []).map(destination => ({ | |
| 26 | + destination, | |
| 27 | + coverage: sampleCoverage[destination.code] ?? 0, | |
| 28 | + triedDishes: 0, | |
| 29 | + totalDishes: destination.dishCount | |
| 30 | + })); | |
| 31 | + | |
| 32 | + return ( | |
| 33 | + <div className="landing-page"> | |
| 34 | + <header className="landing-nav"> | |
| 35 | + <Logo /> | |
| 36 | + <div> | |
| 37 | + <Link className="button button-ghost" to="/login">Sign in</Link> | |
| 38 | + <Link className="button button-primary" to="/register">Start your map</Link> | |
| 39 | + </div> | |
| 40 | + </header> | |
| 41 | + | |
| 42 | + <main> | |
| 43 | + <section className="landing-hero"> | |
| 44 | + <div className="hero-copy"> | |
| 45 | + <span className="eyebrow">A food passport for actual trips</span> | |
| 46 | + <h1>You visited it.<br /><em>Did you taste it?</em></h1> | |
| 47 | + <p> | |
| 48 | + Tasteprint remembers the dishes behind every journey, shows the important experiences you missed, | |
| 49 | + and turns your next destination into a five-bite mission. | |
| 50 | + </p> | |
| 51 | + <div className="hero-actions"> | |
| 52 | + <Link className="button button-primary button-large" to="/register"> | |
| 53 | + Make your Tasteprint <ArrowRight size={18} /> | |
| 54 | + </Link> | |
| 55 | + <Link className="text-link" to="/login?demo=true">Open the demo passport</Link> | |
| 56 | + </div> | |
| 57 | + </div> | |
| 58 | + | |
| 59 | + <div className="hero-ticket" aria-label="Example culinary passport"> | |
| 60 | + <div className="ticket-route"> | |
| 61 | + <span>TLL</span> | |
| 62 | + <i /> | |
| 63 | + <Plane size={17} /> | |
| 64 | + <i /> | |
| 65 | + <span>WORLD</span> | |
| 66 | + </div> | |
| 67 | + <div className="ticket-score"> | |
| 68 | + <span>Culinary coverage</span> | |
| 69 | + <strong>41%</strong> | |
| 70 | + <small>6 food cultures started</small> | |
| 71 | + </div> | |
| 72 | + <WorldMap destinations={mapData} interactive={false} className="hero-world-map" /> | |
| 73 | + <div className="ticket-stamp">TASTED<br />06</div> | |
| 74 | + </div> | |
| 75 | + </section> | |
| 76 | + | |
| 77 | + <section className="landing-proof" aria-label="How Tasteprint works"> | |
| 78 | + <article> | |
| 79 | + <Camera size={22} /> | |
| 80 | + <span>At the table</span> | |
| 81 | + <h2>Log the dish, not only the restaurant.</h2> | |
| 82 | + <p>A photo, place, date, and one note are enough.</p> | |
| 83 | + </article> | |
| 84 | + <article> | |
| 85 | + <MapPinned size={22} /> | |
| 86 | + <span>Before the trip</span> | |
| 87 | + <h2>Get five bites that cover more than the obvious.</h2> | |
| 88 | + <p>Every mission mixes daily food, street bites, mornings, sweets, drinks, and a defining dish.</p> | |
| 89 | + </article> | |
| 90 | + <article> | |
| 91 | + <Check size={22} /> | |
| 92 | + <span>After the trip</span> | |
| 93 | + <h2>See what the destination added to your map.</h2> | |
| 94 | + <p>Share a clear Tasteprint instead of another photo dump.</p> | |
| 95 | + </article> | |
| 96 | + </section> | |
| 97 | + </main> | |
| 98 | + </div> | |
| 99 | + ); | |
| 100 | +} |
added frontend/src/pages/NotFoundPage.tsx +11 −0
| @@ -0,0 +1,11 @@ | ||
| 1 | +import { Link } from "react-router-dom"; | |
| 2 | + | |
| 3 | +export function NotFoundPage() { | |
| 4 | + return ( | |
| 5 | + <main className="not-found-page"> | |
| 6 | + <span className="eyebrow">404, wrong gate</span> | |
| 7 | + <h1>This route is not on the itinerary.</h1> | |
| 8 | + <Link className="button button-primary" to="/">Return to Tasteprint</Link> | |
| 9 | + </main> | |
| 10 | + ); | |
| 11 | +} |
added frontend/src/pages/ProfilePage.tsx +155 −0
| @@ -0,0 +1,155 @@ | ||
| 1 | +import { useState, type FormEvent } from "react"; | |
| 2 | +import { useMutation } from "@tanstack/react-query"; | |
| 3 | +import { Check, Copy, ExternalLink, LockKeyhole, Save, Search, Share2, Trash2 } from "lucide-react"; | |
| 4 | +import { Link, useNavigate } from "react-router-dom"; | |
| 5 | +import { Avatar, PageHeader } from "../components/Ui"; | |
| 6 | +import { useAuth } from "../lib/auth"; | |
| 7 | +import { ApiError, api, jsonBody } from "../lib/api"; | |
| 8 | +import type { Account } from "../types"; | |
| 9 | + | |
| 10 | +export function ProfilePage() { | |
| 11 | + const { user, refresh, deleteAccount } = useAuth(); | |
| 12 | + const navigate = useNavigate(); | |
| 13 | + const [displayName, setDisplayName] = useState(user?.displayName ?? ""); | |
| 14 | + const [homeCity, setHomeCity] = useState(user?.homeCity ?? ""); | |
| 15 | + const [homeCountryCode, setHomeCountryCode] = useState(user?.homeCountryCode ?? ""); | |
| 16 | + const [bio, setBio] = useState(user?.bio ?? ""); | |
| 17 | + const [avatarUrl, setAvatarUrl] = useState(user?.avatarUrl ?? ""); | |
| 18 | + const [profilePublic, setProfilePublic] = useState(user?.profilePublic ?? false); | |
| 19 | + const [compareSlug, setCompareSlug] = useState(""); | |
| 20 | + const [error, setError] = useState(""); | |
| 21 | + const [saved, setSaved] = useState(false); | |
| 22 | + const [copied, setCopied] = useState(false); | |
| 23 | + const [showDelete, setShowDelete] = useState(false); | |
| 24 | + const [deletePassword, setDeletePassword] = useState(""); | |
| 25 | + const [deleteError, setDeleteError] = useState(""); | |
| 26 | + | |
| 27 | + const update = useMutation({ | |
| 28 | + mutationFn: () => api<Account>("/api/v1/auth/me", { | |
| 29 | + method: "PATCH", | |
| 30 | + ...jsonBody({ | |
| 31 | + displayName: displayName.trim(), | |
| 32 | + homeCity: homeCity.trim() || null, | |
| 33 | + homeCountryCode: homeCountryCode.trim().toUpperCase(), | |
| 34 | + bio: bio.trim() || null, | |
| 35 | + avatarUrl: avatarUrl.trim() || null, | |
| 36 | + profilePublic | |
| 37 | + }) | |
| 38 | + }), | |
| 39 | + onSuccess: async () => { | |
| 40 | + await refresh(); | |
| 41 | + setSaved(true); | |
| 42 | + window.setTimeout(() => setSaved(false), 2000); | |
| 43 | + }, | |
| 44 | + onError: caught => setError(caught instanceof ApiError ? caught.message : "Your profile could not be saved.") | |
| 45 | + }); | |
| 46 | + const removeAccount = useMutation({ | |
| 47 | + mutationFn: () => deleteAccount(deletePassword), | |
| 48 | + onSuccess: () => navigate("/", { replace: true }), | |
| 49 | + onError: caught => setDeleteError(caught instanceof ApiError ? caught.message : "The account could not be deleted.") | |
| 50 | + }); | |
| 51 | + | |
| 52 | + if (!user) return null; | |
| 53 | + const publicUrl = `${window.location.origin}/t/${user.shareSlug}`; | |
| 54 | + | |
| 55 | + function submit(event: FormEvent) { | |
| 56 | + event.preventDefault(); | |
| 57 | + setError(""); | |
| 58 | + setSaved(false); | |
| 59 | + update.mutate(); | |
| 60 | + } | |
| 61 | + | |
| 62 | + async function copyLink() { | |
| 63 | + await navigator.clipboard.writeText(publicUrl); | |
| 64 | + setCopied(true); | |
| 65 | + window.setTimeout(() => setCopied(false), 2000); | |
| 66 | + } | |
| 67 | + | |
| 68 | + function compare(event: FormEvent) { | |
| 69 | + event.preventDefault(); | |
| 70 | + const slug = compareSlug.trim().replace(/^.*\/t\//, ""); | |
| 71 | + if (slug) navigate(`/app/compare/${encodeURIComponent(slug)}`); | |
| 72 | + } | |
| 73 | + | |
| 74 | + return ( | |
| 75 | + <div className="page profile-page"> | |
| 76 | + <PageHeader eyebrow="Your passport" title="Profile and sharing" | |
| 77 | + copy="Choose what appears on your public Tasteprint, then send one link instead of a photo dump." /> | |
| 78 | + | |
| 79 | + <div className="profile-layout"> | |
| 80 | + <form className="panel profile-form" onSubmit={submit}> | |
| 81 | + <div className="profile-identity"> | |
| 82 | + <Avatar name={displayName || user.displayName} url={avatarUrl} size="large" /> | |
| 83 | + <div><span className="eyebrow">Traveler profile</span><h2>{displayName || user.displayName}</h2><p>{user.email}</p></div> | |
| 84 | + </div> | |
| 85 | + <div className="form-grid form-grid-two"> | |
| 86 | + <label><span>Display name</span><input value={displayName} onChange={event => setDisplayName(event.target.value)} maxLength={80} required /></label> | |
| 87 | + <label><span>Avatar image URL</span><input type="url" value={avatarUrl} onChange={event => setAvatarUrl(event.target.value)} placeholder="https://..." maxLength={500} /></label> | |
| 88 | + <label><span>Home city</span><input value={homeCity} onChange={event => setHomeCity(event.target.value)} placeholder="Tallinn" maxLength={100} /></label> | |
| 89 | + <label><span>Home country code</span><input value={homeCountryCode} onChange={event => setHomeCountryCode(event.target.value.toUpperCase())} placeholder="EE" maxLength={2} pattern="[A-Za-z]{2}" /></label> | |
| 90 | + </div> | |
| 91 | + <label><span>Short bio</span><textarea value={bio} onChange={event => setBio(event.target.value)} rows={3} maxLength={280} placeholder="I travel for the table." /></label> | |
| 92 | + <label className="privacy-toggle"> | |
| 93 | + <input type="checkbox" checked={profilePublic} onChange={event => setProfilePublic(event.target.checked)} /> | |
| 94 | + <span><strong>Public Tasteprint</strong><small>Anyone with your link can see your map, stats, bio, and recent tastings.</small></span> | |
| 95 | + </label> | |
| 96 | + {error && <p className="form-error" role="alert">{error}</p>} | |
| 97 | + <button className="button button-primary" type="submit" disabled={update.isPending}> | |
| 98 | + {saved ? <Check size={17} /> : <Save size={17} />} {saved ? "Saved" : "Save profile"} | |
| 99 | + </button> | |
| 100 | + </form> | |
| 101 | + | |
| 102 | + <aside className="profile-side"> | |
| 103 | + <section className={`panel share-panel ${profilePublic ? "is-public" : ""}`}> | |
| 104 | + {profilePublic ? <Share2 size={24} /> : <LockKeyhole size={24} />} | |
| 105 | + <span className="eyebrow">Share link</span> | |
| 106 | + <h2>{profilePublic ? "Your Tasteprint is visible" : "Your Tasteprint is private"}</h2> | |
| 107 | + <div className="share-url"><span>{publicUrl}</span><button type="button" onClick={() => void copyLink()} aria-label="Copy public link">{copied ? <Check size={17} /> : <Copy size={17} />}</button></div> | |
| 108 | + <div className="share-actions"> | |
| 109 | + <button className="button button-outline" type="button" onClick={() => void copyLink()} disabled={!profilePublic}>{copied ? "Copied" : "Copy link"}</button> | |
| 110 | + {profilePublic && <Link className="button button-ghost" to={`/t/${user.shareSlug}`} target="_blank">Preview <ExternalLink size={16} /></Link>} | |
| 111 | + </div> | |
| 112 | + {!profilePublic && <p>Turn on public sharing and save before sending the link.</p>} | |
| 113 | + </section> | |
| 114 | + | |
| 115 | + <section className="panel compare-start"> | |
| 116 | + <Search size={23} /> | |
| 117 | + <span className="eyebrow">Compare taste maps</span> | |
| 118 | + <h2>What should you eat together?</h2> | |
| 119 | + <p>Paste a friend's Tasteprint link or enter their handle.</p> | |
| 120 | + <form onSubmit={compare}> | |
| 121 | + <input value={compareSlug} onChange={event => setCompareSlug(event.target.value)} placeholder="friend-handle" required /> | |
| 122 | + <button className="button button-ink" type="submit">Compare</button> | |
| 123 | + </form> | |
| 124 | + </section> | |
| 125 | + <section className="panel danger-panel"> | |
| 126 | + <Trash2 size={22} /> | |
| 127 | + <span className="eyebrow">Account data</span> | |
| 128 | + <h2>Delete your Tasteprint</h2> | |
| 129 | + <p>This permanently removes your profile, sessions, tastings, trips, and owned challenges.</p> | |
| 130 | + {!showDelete ? ( | |
| 131 | + <button className="danger-link" type="button" onClick={() => setShowDelete(true)}>Delete account</button> | |
| 132 | + ) : ( | |
| 133 | + <form onSubmit={event => { | |
| 134 | + event.preventDefault(); | |
| 135 | + setDeleteError(""); | |
| 136 | + removeAccount.mutate(); | |
| 137 | + }}> | |
| 138 | + <label> | |
| 139 | + <span>Confirm with your password</span> | |
| 140 | + <input type="password" value={deletePassword} onChange={event => setDeletePassword(event.target.value)} | |
| 141 | + maxLength={72} required autoComplete="current-password" /> | |
| 142 | + </label> | |
| 143 | + {deleteError && <p className="form-error" role="alert">{deleteError}</p>} | |
| 144 | + <div> | |
| 145 | + <button className="button button-ghost" type="button" onClick={() => setShowDelete(false)}>Cancel</button> | |
| 146 | + <button className="button button-danger" type="submit" disabled={removeAccount.isPending}>Delete permanently</button> | |
| 147 | + </div> | |
| 148 | + </form> | |
| 149 | + )} | |
| 150 | + </section> | |
| 151 | + </aside> | |
| 152 | + </div> | |
| 153 | + </div> | |
| 154 | + ); | |
| 155 | +} |
added frontend/src/pages/PublicTasteprintPage.tsx +81 −0
| @@ -0,0 +1,81 @@ | ||
| 1 | +import { useQuery } from "@tanstack/react-query"; | |
| 2 | +import { ArrowRight, MapPin, Utensils } from "lucide-react"; | |
| 3 | +import { Link, useParams } from "react-router-dom"; | |
| 4 | +import { Logo } from "../components/Logo"; | |
| 5 | +import { Avatar, ErrorState, LoadingScreen } from "../components/Ui"; | |
| 6 | +import { WorldMap } from "../components/WorldMap"; | |
| 7 | +import { api } from "../lib/api"; | |
| 8 | +import { formatDate } from "../lib/format"; | |
| 9 | +import type { PublicTasteprint } from "../types"; | |
| 10 | + | |
| 11 | +export function PublicTasteprintPage() { | |
| 12 | + const { shareSlug } = useParams(); | |
| 13 | + const tasteprint = useQuery({ | |
| 14 | + queryKey: ["public-tasteprint", shareSlug], | |
| 15 | + queryFn: () => api<PublicTasteprint>(`/api/v1/public/tasteprints/${shareSlug}`), | |
| 16 | + enabled: Boolean(shareSlug), | |
| 17 | + retry: false | |
| 18 | + }); | |
| 19 | + | |
| 20 | + if (tasteprint.isLoading) return <div className="public-state"><LoadingScreen /></div>; | |
| 21 | + if (tasteprint.isError || !tasteprint.data) { | |
| 22 | + return <div className="public-state"><Logo /><ErrorState message="This Tasteprint is private or does not exist." /><Link className="button button-primary" to="/">Open Tasteprint</Link></div>; | |
| 23 | + } | |
| 24 | + const data = tasteprint.data; | |
| 25 | + const explored = data.tasteprint.destinations.filter(item => item.coverage > 0).sort((a, b) => b.coverage - a.coverage); | |
| 26 | + | |
| 27 | + return ( | |
| 28 | + <div className="public-page"> | |
| 29 | + <header className="public-nav"><Logo /><Link className="button button-primary" to="/register">Make your own</Link></header> | |
| 30 | + <main className="public-main"> | |
| 31 | + <section className="public-passport"> | |
| 32 | + <div className="public-person"> | |
| 33 | + <Avatar name={data.user.displayName} url={data.user.avatarUrl} size="large" /> | |
| 34 | + <div><span className="eyebrow eyebrow-light">Culinary passport</span><h1>{data.user.displayName}'s Tasteprint</h1> | |
| 35 | + {(data.user.homeCity || data.user.homeCountryCode) && <p><MapPin size={15} /> {[data.user.homeCity, data.user.homeCountryCode].filter(Boolean).join(", ")}</p>} | |
| 36 | + </div> | |
| 37 | + </div> | |
| 38 | + {data.user.bio && <p className="public-bio">{data.user.bio}</p>} | |
| 39 | + <WorldMap destinations={data.tasteprint.destinations} interactive={false} className="public-map" /> | |
| 40 | + <div className="ticket-stats public-stats"> | |
| 41 | + <div><strong>{data.tasteprint.stats.uniqueDishes}</strong><span>Dishes</span></div> | |
| 42 | + <div><strong>{data.tasteprint.stats.countriesTasted}</strong><span>Countries</span></div> | |
| 43 | + <div><strong>{data.tasteprint.stats.regionsTasted}</strong><span>Regions</span></div> | |
| 44 | + <div><strong>{data.tasteprint.stats.worldCoverage}%</strong><span>Atlas</span></div> | |
| 45 | + </div> | |
| 46 | + </section> | |
| 47 | + | |
| 48 | + <section className="public-section"> | |
| 49 | + <div className="section-heading"><div><span className="eyebrow">Food cultures</span><h2>Strongest parts of the map</h2></div></div> | |
| 50 | + <div className="public-country-list"> | |
| 51 | + {explored.slice(0, 6).map(item => ( | |
| 52 | + <article key={item.destination.code}> | |
| 53 | + <span style={{ backgroundColor: item.destination.accentColor }}>{item.destination.code}</span> | |
| 54 | + <div><strong>{item.destination.name}</strong><small>{item.triedDishes} of {item.totalDishes} core dishes</small></div> | |
| 55 | + <b>{item.coverage}%</b> | |
| 56 | + </article> | |
| 57 | + ))} | |
| 58 | + </div> | |
| 59 | + </section> | |
| 60 | + | |
| 61 | + {data.tasteprint.recentTastings.length > 0 && ( | |
| 62 | + <section className="public-section"> | |
| 63 | + <div className="section-heading"><div><span className="eyebrow">Recent marks</span><h2>Latest dishes remembered</h2></div></div> | |
| 64 | + <div className="public-tasting-grid"> | |
| 65 | + {data.tasteprint.recentTastings.slice(0, 6).map(tasting => ( | |
| 66 | + <article key={tasting.id}> | |
| 67 | + <div className="public-tasting-photo" style={{ backgroundColor: tasting.dish.destinationAccent }}> | |
| 68 | + {tasting.photoUrl ? <img src={tasting.photoUrl} alt="" /> : <Utensils size={24} />} | |
| 69 | + </div> | |
| 70 | + <div><span>{tasting.dish.destinationName}</span><h3>{tasting.dish.name}</h3><p>{tasting.city}, {formatDate(tasting.tastedOn)}</p></div> | |
| 71 | + </article> | |
| 72 | + ))} | |
| 73 | + </div> | |
| 74 | + </section> | |
| 75 | + )} | |
| 76 | + | |
| 77 | + <section className="public-cta"><div><span className="eyebrow eyebrow-light">Your turn</span><h2>Which parts of the world have you actually tasted?</h2></div><Link className="button button-light" to="/register">Start your map <ArrowRight size={17} /></Link></section> | |
| 78 | + </main> | |
| 79 | + </div> | |
| 80 | + ); | |
| 81 | +} |
added frontend/src/pages/TastingsPage.tsx +102 −0
| @@ -0,0 +1,102 @@ | ||
| 1 | +import { useMemo, useState } from "react"; | |
| 2 | +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | +import { Edit3, MapPin, Plus, Search, Star, Trash2 } from "lucide-react"; | |
| 4 | +import { useQuickTaste } from "../components/QuickTaste"; | |
| 5 | +import { EmptyState, ErrorState, LoadingScreen, PageHeader } from "../components/Ui"; | |
| 6 | +import { api } from "../lib/api"; | |
| 7 | +import { formatDate } from "../lib/format"; | |
| 8 | +import type { TastingPage } from "../types"; | |
| 9 | + | |
| 10 | +export function TastingsPage() { | |
| 11 | + const { openTaste } = useQuickTaste(); | |
| 12 | + const queryClient = useQueryClient(); | |
| 13 | + const [search, setSearch] = useState(""); | |
| 14 | + const tastings = useQuery({ | |
| 15 | + queryKey: ["tastings", 0, 50], | |
| 16 | + queryFn: () => api<TastingPage>("/api/v1/tastings?page=0&size=50") | |
| 17 | + }); | |
| 18 | + const remove = useMutation({ | |
| 19 | + mutationFn: (id: string) => api<void>(`/api/v1/tastings/${id}`, { method: "DELETE" }), | |
| 20 | + onSuccess: () => queryClient.invalidateQueries() | |
| 21 | + }); | |
| 22 | + | |
| 23 | + const filtered = useMemo(() => tastings.data?.items.filter(item => { | |
| 24 | + const needle = search.trim().toLowerCase(); | |
| 25 | + if (!needle) return true; | |
| 26 | + return item.dish.name.toLowerCase().includes(needle) | |
| 27 | + || item.city.toLowerCase().includes(needle) | |
| 28 | + || item.dish.destinationName.toLowerCase().includes(needle) | |
| 29 | + || item.restaurantName?.toLowerCase().includes(needle); | |
| 30 | + }) ?? [], [search, tastings.data]); | |
| 31 | + | |
| 32 | + if (tastings.isLoading) return <LoadingScreen label="Opening your tasting journal" />; | |
| 33 | + if (tastings.isError) return <ErrorState message="Your tasting journal could not be loaded." />; | |
| 34 | + | |
| 35 | + return ( | |
| 36 | + <div className="page tastings-page"> | |
| 37 | + <PageHeader | |
| 38 | + eyebrow="Personal archive" | |
| 39 | + title="Your tasting journal" | |
| 40 | + copy="Restaurants change. This keeps the dish, place, and detail you want to remember." | |
| 41 | + action={<button className="button button-primary" onClick={() => openTaste()}><Plus size={18} /> Log a taste</button>} | |
| 42 | + /> | |
| 43 | + | |
| 44 | + <div className="journal-toolbar"> | |
| 45 | + <label className="search-field"> | |
| 46 | + <Search size={17} /> | |
| 47 | + <span className="sr-only">Search tasting journal</span> | |
| 48 | + <input value={search} onChange={event => setSearch(event.target.value)} placeholder="Dish, country, city, or place" /> | |
| 49 | + </label> | |
| 50 | + <span>{filtered.length} of {tastings.data?.totalItems ?? 0} entries</span> | |
| 51 | + </div> | |
| 52 | + | |
| 53 | + {filtered.length ? ( | |
| 54 | + <section className="journal-list" aria-label="Tastings"> | |
| 55 | + {filtered.map(tasting => ( | |
| 56 | + <article className="journal-entry" key={tasting.id}> | |
| 57 | + <div className="journal-image" style={{ backgroundColor: tasting.dish.destinationAccent }}> | |
| 58 | + {tasting.photoUrl | |
| 59 | + ? <img src={tasting.photoUrl} alt={`${tasting.dish.name} in ${tasting.city}`} /> | |
| 60 | + : <span>{tasting.dish.destinationCode}</span>} | |
| 61 | + </div> | |
| 62 | + <div className="journal-main"> | |
| 63 | + <div className="journal-title-row"> | |
| 64 | + <div> | |
| 65 | + <span className="eyebrow">{tasting.dish.destinationName} / {tasting.dish.categoryLabel}</span> | |
| 66 | + <h2>{tasting.dish.name}</h2> | |
| 67 | + </div> | |
| 68 | + <div className="journal-rating" aria-label={`${tasting.rating} out of 5`}> | |
| 69 | + <Star size={15} fill="currentColor" /> {tasting.rating} | |
| 70 | + </div> | |
| 71 | + </div> | |
| 72 | + <div className="journal-place"> | |
| 73 | + <MapPin size={14} /> | |
| 74 | + <span>{tasting.restaurantName ? `${tasting.restaurantName}, ` : ""}{tasting.city}</span> | |
| 75 | + <time dateTime={tasting.tastedOn}>{formatDate(tasting.tastedOn)}</time> | |
| 76 | + </div> | |
| 77 | + {tasting.note && <blockquote>{tasting.note}</blockquote>} | |
| 78 | + </div> | |
| 79 | + <div className="journal-actions"> | |
| 80 | + <button className="icon-button" type="button" onClick={() => openTaste({ tasting })} aria-label={`Edit ${tasting.dish.name}`}> | |
| 81 | + <Edit3 size={17} /> | |
| 82 | + </button> | |
| 83 | + <button className="icon-button icon-button-danger" type="button" disabled={remove.isPending} | |
| 84 | + onClick={() => { | |
| 85 | + if (window.confirm(`Delete your ${tasting.dish.name} entry?`)) remove.mutate(tasting.id); | |
| 86 | + }} aria-label={`Delete ${tasting.dish.name}`}> | |
| 87 | + <Trash2 size={17} /> | |
| 88 | + </button> | |
| 89 | + </div> | |
| 90 | + </article> | |
| 91 | + ))} | |
| 92 | + </section> | |
| 93 | + ) : ( | |
| 94 | + <EmptyState | |
| 95 | + title={search ? "No journal entries match" : "Your journal is ready"} | |
| 96 | + copy={search ? "Try a dish, city, country, or restaurant name." : "Log your first memorable dish to begin the map."} | |
| 97 | + action={!search && <button className="button button-primary" onClick={() => openTaste()}>Log first taste</button>} | |
| 98 | + /> | |
| 99 | + )} | |
| 100 | + </div> | |
| 101 | + ); | |
| 102 | +} |
added frontend/src/pages/TripDetailsPage.tsx +86 −0
| @@ -0,0 +1,86 @@ | ||
| 1 | +import { useState, type CSSProperties } from "react"; | |
| 2 | +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 3 | +import { ArrowLeft, CalendarDays, Check, Edit3, MapPin, Plus, Trash2 } from "lucide-react"; | |
| 4 | +import { Link, useNavigate, useParams } from "react-router-dom"; | |
| 5 | +import { ProgressRing } from "../components/ProgressRing"; | |
| 6 | +import { useQuickTaste } from "../components/QuickTaste"; | |
| 7 | +import { TripFormDialog } from "../components/TripFormDialog"; | |
| 8 | +import { ErrorState, LoadingScreen, StatusPill } from "../components/Ui"; | |
| 9 | +import { api } from "../lib/api"; | |
| 10 | +import { formatDateRange } from "../lib/format"; | |
| 11 | +import type { Trip } from "../types"; | |
| 12 | + | |
| 13 | +export function TripDetailsPage() { | |
| 14 | + const { tripId = "" } = useParams(); | |
| 15 | + const navigate = useNavigate(); | |
| 16 | + const queryClient = useQueryClient(); | |
| 17 | + const { openTaste } = useQuickTaste(); | |
| 18 | + const [editing, setEditing] = useState(false); | |
| 19 | + const trip = useQuery({ | |
| 20 | + queryKey: ["trip", tripId], | |
| 21 | + queryFn: () => api<Trip>(`/api/v1/trips/${tripId}`), | |
| 22 | + enabled: Boolean(tripId) | |
| 23 | + }); | |
| 24 | + const remove = useMutation({ | |
| 25 | + mutationFn: () => api<void>(`/api/v1/trips/${tripId}`, { method: "DELETE" }), | |
| 26 | + onSuccess: async () => { | |
| 27 | + await queryClient.invalidateQueries(); | |
| 28 | + navigate("/app/trips"); | |
| 29 | + } | |
| 30 | + }); | |
| 31 | + | |
| 32 | + if (trip.isLoading) return <LoadingScreen label="Opening your food mission" />; | |
| 33 | + if (trip.isError || !trip.data) return <ErrorState message="This trip could not be loaded." />; | |
| 34 | + const data = trip.data; | |
| 35 | + | |
| 36 | + return ( | |
| 37 | + <div className="page trip-detail-page"> | |
| 38 | + <Link className="back-link" to="/app/trips"><ArrowLeft size={16} /> Trips</Link> | |
| 39 | + <section className="trip-detail-hero" style={{ "--destination-accent": data.destination.accentColor } as CSSProperties}> | |
| 40 | + <div> | |
| 41 | + <div className="trip-detail-labels"><StatusPill status={data.status} /><span>{data.destination.code}</span></div> | |
| 42 | + <h1>{data.city}</h1> | |
| 43 | + <p>{data.destination.name}</p> | |
| 44 | + <div className="trip-detail-dates"><CalendarDays size={16} /> {formatDateRange(data.startsOn, data.endsOn)}</div> | |
| 45 | + </div> | |
| 46 | + <div className="trip-detail-score"> | |
| 47 | + <ProgressRing value={data.culinaryCoverage} size="large" label="trip coverage" /> | |
| 48 | + <span>Tasted there during this trip</span> | |
| 49 | + </div> | |
| 50 | + <div className="trip-detail-actions"> | |
| 51 | + <button className="icon-button" type="button" onClick={() => setEditing(true)} aria-label="Edit trip"><Edit3 size={17} /></button> | |
| 52 | + <button className="icon-button icon-button-danger" type="button" disabled={remove.isPending} | |
| 53 | + onClick={() => window.confirm("Delete this trip and its mission? Your tasting journal stays intact.") && remove.mutate()} | |
| 54 | + aria-label="Delete trip"><Trash2 size={17} /></button> | |
| 55 | + </div> | |
| 56 | + </section> | |
| 57 | + | |
| 58 | + <section className="mission-sheet"> | |
| 59 | + <header> | |
| 60 | + <div> | |
| 61 | + <span className="eyebrow">Five-bite mission</span> | |
| 62 | + <h2>{data.missionCompleted === data.mission.length ? "Mission complete" : `${data.mission.length - data.missionCompleted} tastes left`}</h2> | |
| 63 | + </div> | |
| 64 | + <span className="mission-route"><MapPin size={15} /> Log them in {data.destination.name} during these dates</span> | |
| 65 | + </header> | |
| 66 | + <ol> | |
| 67 | + {data.mission.map(item => ( | |
| 68 | + <li key={item.dish.slug} className={item.completed ? "mission-complete" : ""}> | |
| 69 | + <span className="mission-sequence">{item.completed ? <Check size={18} /> : String(item.position).padStart(2, "0")}</span> | |
| 70 | + <div className="mission-dish-copy"> | |
| 71 | + <span>{item.dish.categoryLabel}</span> | |
| 72 | + <h3>{item.dish.name}</h3> | |
| 73 | + <p>{item.dish.description}</p> | |
| 74 | + <small>{item.dish.whyItMatters}</small> | |
| 75 | + </div> | |
| 76 | + {item.completed | |
| 77 | + ? <span className="mission-done">Stamped</span> | |
| 78 | + : <button className="button button-outline" onClick={() => openTaste({ dish: item.dish })}><Plus size={16} /> Log</button>} | |
| 79 | + </li> | |
| 80 | + ))} | |
| 81 | + </ol> | |
| 82 | + </section> | |
| 83 | + {editing && <TripFormDialog trip={data} onClose={() => setEditing(false)} />} | |
| 84 | + </div> | |
| 85 | + ); | |
| 86 | +} |
added frontend/src/pages/TripsPage.tsx +67 −0
| @@ -0,0 +1,67 @@ | ||
| 1 | +import { useState, type CSSProperties } from "react"; | |
| 2 | +import { useQuery } from "@tanstack/react-query"; | |
| 3 | +import { ArrowRight, CalendarDays, Check, MapPin, Plane, Plus } from "lucide-react"; | |
| 4 | +import { Link } from "react-router-dom"; | |
| 5 | +import { ProgressRing } from "../components/ProgressRing"; | |
| 6 | +import { TripFormDialog } from "../components/TripFormDialog"; | |
| 7 | +import { EmptyState, ErrorState, LoadingScreen, PageHeader, StatusPill } from "../components/Ui"; | |
| 8 | +import { api } from "../lib/api"; | |
| 9 | +import { formatDateRange } from "../lib/format"; | |
| 10 | +import type { Trip } from "../types"; | |
| 11 | + | |
| 12 | +export function TripsPage() { | |
| 13 | + const [creating, setCreating] = useState(false); | |
| 14 | + const trips = useQuery({ | |
| 15 | + queryKey: ["trips"], | |
| 16 | + queryFn: () => api<Trip[]>("/api/v1/trips") | |
| 17 | + }); | |
| 18 | + | |
| 19 | + if (trips.isLoading) return <LoadingScreen label="Opening your trips" />; | |
| 20 | + if (trips.isError) return <ErrorState message="Your trips could not be loaded." />; | |
| 21 | + | |
| 22 | + return ( | |
| 23 | + <div className="page trips-page"> | |
| 24 | + <PageHeader | |
| 25 | + eyebrow="Travel plans" | |
| 26 | + title="Build the trip around five bites." | |
| 27 | + copy="A mission stays short on purpose. It gives you range without turning dinner into homework." | |
| 28 | + action={<button className="button button-primary" onClick={() => setCreating(true)}><Plus size={18} /> Add trip</button>} | |
| 29 | + /> | |
| 30 | + | |
| 31 | + {trips.data?.length ? ( | |
| 32 | + <section className="trip-list" aria-label="Trips"> | |
| 33 | + {trips.data.map(trip => ( | |
| 34 | + <Link className="trip-card" key={trip.id} to={`/app/trips/${trip.id}`} | |
| 35 | + style={{ "--destination-accent": trip.destination.accentColor } as CSSProperties}> | |
| 36 | + <div className="trip-card-route"> | |
| 37 | + <span>TLL</span><i /><Plane size={17} /><i /><span>{trip.destination.code}</span> | |
| 38 | + </div> | |
| 39 | + <div className="trip-card-main"> | |
| 40 | + <div> | |
| 41 | + <StatusPill status={trip.status} /> | |
| 42 | + <h2>{trip.city}</h2> | |
| 43 | + <p>{trip.destination.name}</p> | |
| 44 | + </div> | |
| 45 | + <ProgressRing value={trip.culinaryCoverage} size="medium" label="trip coverage" /> | |
| 46 | + </div> | |
| 47 | + <div className="trip-card-meta"> | |
| 48 | + <span><CalendarDays size={14} /> {formatDateRange(trip.startsOn, trip.endsOn)}</span> | |
| 49 | + <span><Check size={14} /> {trip.missionCompleted}/{trip.mission.length} mission bites</span> | |
| 50 | + </div> | |
| 51 | + <footer> | |
| 52 | + <span>Open food mission</span><ArrowRight size={17} /> | |
| 53 | + </footer> | |
| 54 | + </Link> | |
| 55 | + ))} | |
| 56 | + </section> | |
| 57 | + ) : ( | |
| 58 | + <EmptyState | |
| 59 | + title="No food missions yet" | |
| 60 | + copy="Add a destination. Tasteprint will choose five different parts of its food culture for you." | |
| 61 | + action={<button className="button button-primary" onClick={() => setCreating(true)}><MapPin size={17} /> Add first trip</button>} | |
| 62 | + /> | |
| 63 | + )} | |
| 64 | + {creating && <TripFormDialog onClose={() => setCreating(false)} />} | |
| 65 | + </div> | |
| 66 | + ); | |
| 67 | +} |
added frontend/src/test/setup.ts +1 −0
| @@ -0,0 +1 @@ | ||
| 1 | +import "@testing-library/jest-dom/vitest"; |
added frontend/src/types.ts +210 −0
| @@ -0,0 +1,210 @@ | ||
| 1 | +export type DishCategory = "STAPLE" | "STREET" | "BREAKFAST" | "SWEET" | "DRINK" | "SIGNATURE"; | |
| 2 | + | |
| 3 | +export interface Account { | |
| 4 | + id: string; | |
| 5 | + displayName: string; | |
| 6 | + email: string; | |
| 7 | + shareSlug: string; | |
| 8 | + homeCity: string | null; | |
| 9 | + homeCountryCode: string | null; | |
| 10 | + bio: string | null; | |
| 11 | + avatarUrl: string | null; | |
| 12 | + profilePublic: boolean; | |
| 13 | + memberSince: string; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export type PublicAccount = Omit<Account, "email" | "profilePublic">; | |
| 17 | + | |
| 18 | +export interface AuthSession { | |
| 19 | + token: string; | |
| 20 | + user: Account; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export interface Destination { | |
| 24 | + code: string; | |
| 25 | + name: string; | |
| 26 | + localName: string; | |
| 27 | + regionName: string; | |
| 28 | + summary: string; | |
| 29 | + centerLat: number; | |
| 30 | + centerLng: number; | |
| 31 | + accentColor: string; | |
| 32 | + dishCount: number; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export interface Dish { | |
| 36 | + slug: string; | |
| 37 | + destinationCode: string; | |
| 38 | + destinationName: string; | |
| 39 | + destinationAccent: string; | |
| 40 | + name: string; | |
| 41 | + localName: string | null; | |
| 42 | + category: DishCategory; | |
| 43 | + categoryLabel: string; | |
| 44 | + description: string; | |
| 45 | + whyItMatters: string; | |
| 46 | + importance: number; | |
| 47 | + imageUrl: string | null; | |
| 48 | + vegetarian: boolean; | |
| 49 | + spicyLevel: number; | |
| 50 | +} | |
| 51 | + | |
| 52 | +export interface DestinationDetails { | |
| 53 | + destination: Destination; | |
| 54 | + dishes: Dish[]; | |
| 55 | +} | |
| 56 | + | |
| 57 | +export interface Tasting { | |
| 58 | + id: string; | |
| 59 | + dish: Dish; | |
| 60 | + restaurantName: string | null; | |
| 61 | + city: string; | |
| 62 | + countryCode: string; | |
| 63 | + tastedOn: string; | |
| 64 | + rating: number; | |
| 65 | + note: string | null; | |
| 66 | + photoUrl: string | null; | |
| 67 | + latitude: number | null; | |
| 68 | + longitude: number | null; | |
| 69 | + createdAt: string; | |
| 70 | +} | |
| 71 | + | |
| 72 | +export interface TastingPage { | |
| 73 | + items: Tasting[]; | |
| 74 | + page: number; | |
| 75 | + size: number; | |
| 76 | + totalItems: number; | |
| 77 | + totalPages: number; | |
| 78 | + hasMore: boolean; | |
| 79 | +} | |
| 80 | + | |
| 81 | +export interface TasteStats { | |
| 82 | + totalTastings: number; | |
| 83 | + uniqueDishes: number; | |
| 84 | + countriesTasted: number; | |
| 85 | + regionsTasted: number; | |
| 86 | + worldCoverage: number; | |
| 87 | +} | |
| 88 | + | |
| 89 | +export interface DestinationProgress { | |
| 90 | + destination: Destination; | |
| 91 | + coverage: number; | |
| 92 | + triedDishes: number; | |
| 93 | + totalDishes: number; | |
| 94 | +} | |
| 95 | + | |
| 96 | +export interface DishProgress { | |
| 97 | + dish: Dish; | |
| 98 | + tried: boolean; | |
| 99 | +} | |
| 100 | + | |
| 101 | +export interface DestinationProgressDetails { | |
| 102 | + progress: DestinationProgress; | |
| 103 | + dishes: DishProgress[]; | |
| 104 | +} | |
| 105 | + | |
| 106 | +export interface TasteSnapshot { | |
| 107 | + stats: TasteStats; | |
| 108 | + destinations: DestinationProgress[]; | |
| 109 | + importantMissing: Dish[]; | |
| 110 | + recentTastings: Tasting[]; | |
| 111 | +} | |
| 112 | + | |
| 113 | +export type TripStatus = "UPCOMING" | "ACTIVE" | "COMPLETED"; | |
| 114 | + | |
| 115 | +export interface MissionItem { | |
| 116 | + position: number; | |
| 117 | + dish: Dish; | |
| 118 | + completed: boolean; | |
| 119 | +} | |
| 120 | + | |
| 121 | +export interface Trip { | |
| 122 | + id: string; | |
| 123 | + destination: Destination; | |
| 124 | + city: string; | |
| 125 | + startsOn: string; | |
| 126 | + endsOn: string; | |
| 127 | + status: TripStatus; | |
| 128 | + culinaryCoverage: number; | |
| 129 | + missionCompleted: number; | |
| 130 | + mission: MissionItem[]; | |
| 131 | +} | |
| 132 | + | |
| 133 | +export interface Dashboard { | |
| 134 | + user: Account; | |
| 135 | + tasteprint: TasteSnapshot; | |
| 136 | + nextTrip: Trip | null; | |
| 137 | +} | |
| 138 | + | |
| 139 | +export type ChallengeStatus = "UPCOMING" | "ACTIVE" | "COMPLETED"; | |
| 140 | + | |
| 141 | +export interface ChallengeParticipant { | |
| 142 | + user: PublicAccount; | |
| 143 | + contributedDishes: number; | |
| 144 | + completedDishSlugs: string[]; | |
| 145 | +} | |
| 146 | + | |
| 147 | +export interface Challenge { | |
| 148 | + id: string; | |
| 149 | + ownerId: string; | |
| 150 | + title: string; | |
| 151 | + destination: Destination; | |
| 152 | + joinCode: string; | |
| 153 | + startsOn: string; | |
| 154 | + endsOn: string; | |
| 155 | + status: ChallengeStatus; | |
| 156 | + groupCoverage: number; | |
| 157 | + dishes: DishProgress[]; | |
| 158 | + participants: ChallengeParticipant[]; | |
| 159 | +} | |
| 160 | + | |
| 161 | +export interface PublicTasteprint { | |
| 162 | + user: PublicAccount; | |
| 163 | + tasteprint: Omit<TasteSnapshot, "recentTastings"> & { | |
| 164 | + recentTastings: Array<Omit<Tasting, "restaurantName" | "latitude" | "longitude" | "createdAt">>; | |
| 165 | + }; | |
| 166 | +} | |
| 167 | + | |
| 168 | +export interface TasteComparison { | |
| 169 | + otherUser: PublicAccount; | |
| 170 | + overlapScore: number; | |
| 171 | + sharedDishes: number; | |
| 172 | + sharedCountries: Destination[]; | |
| 173 | + yourUniqueCountries: Destination[]; | |
| 174 | + theirUniqueCountries: Destination[]; | |
| 175 | + suggestedSharedBite: Dish | null; | |
| 176 | +} | |
| 177 | + | |
| 178 | +export interface ApiProblem { | |
| 179 | + title?: string; | |
| 180 | + detail?: string; | |
| 181 | + status?: number; | |
| 182 | + errors?: Record<string, string>; | |
| 183 | +} | |
| 184 | + | |
| 185 | +export interface SaveTastingInput { | |
| 186 | + dishSlug: string; | |
| 187 | + restaurantName: string | null; | |
| 188 | + city: string; | |
| 189 | + countryCode: string; | |
| 190 | + tastedOn: string; | |
| 191 | + rating: number; | |
| 192 | + note: string | null; | |
| 193 | + photoUrl: string | null; | |
| 194 | + latitude: number | null; | |
| 195 | + longitude: number | null; | |
| 196 | +} | |
| 197 | + | |
| 198 | +export interface SaveTripInput { | |
| 199 | + destinationCode: string; | |
| 200 | + city: string; | |
| 201 | + startsOn: string; | |
| 202 | + endsOn: string; | |
| 203 | +} | |
| 204 | + | |
| 205 | +export interface CreateChallengeInput { | |
| 206 | + title: string; | |
| 207 | + destinationCode: string; | |
| 208 | + startsOn: string; | |
| 209 | + endsOn: string; | |
| 210 | +} |
added frontend/src/vite-env.d.ts +11 −0
| @@ -0,0 +1,11 @@ | ||
| 1 | +/// <reference types="vite/client" /> | |
| 2 | + | |
| 3 | +declare module "world-atlas/countries-110m.json" { | |
| 4 | + const world: { | |
| 5 | + type: "Topology"; | |
| 6 | + objects: Record<string, unknown>; | |
| 7 | + arcs: unknown[]; | |
| 8 | + transform?: unknown; | |
| 9 | + }; | |
| 10 | + export default world; | |
| 11 | +} |
added frontend/tsconfig.json +21 −0
| @@ -0,0 +1,21 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "target": "ES2022", | |
| 4 | + "useDefineForClassFields": true, | |
| 5 | + "lib": ["ES2022", "DOM", "DOM.Iterable"], | |
| 6 | + "allowJs": false, | |
| 7 | + "skipLibCheck": true, | |
| 8 | + "esModuleInterop": true, | |
| 9 | + "allowSyntheticDefaultImports": true, | |
| 10 | + "strict": true, | |
| 11 | + "forceConsistentCasingInFileNames": true, | |
| 12 | + "module": "ESNext", | |
| 13 | + "moduleResolution": "Bundler", | |
| 14 | + "resolveJsonModule": true, | |
| 15 | + "isolatedModules": true, | |
| 16 | + "noEmit": true, | |
| 17 | + "jsx": "react-jsx", | |
| 18 | + "types": ["vite/client", "vitest/globals"] | |
| 19 | + }, | |
| 20 | + "include": ["src", "vite.config.ts", "eslint.config.js"] | |
| 21 | +} |
added frontend/vite.config.ts +18 −0
| @@ -0,0 +1,18 @@ | ||
| 1 | +import { defineConfig } from "vitest/config"; | |
| 2 | +import react from "@vitejs/plugin-react"; | |
| 3 | + | |
| 4 | +export default defineConfig({ | |
| 5 | + plugins: [react()], | |
| 6 | + server: { | |
| 7 | + port: 5173, | |
| 8 | + proxy: { | |
| 9 | + "/api": "http://localhost:8080", | |
| 10 | + "/uploads": "http://localhost:8080" | |
| 11 | + } | |
| 12 | + }, | |
| 13 | + test: { | |
| 14 | + environment: "jsdom", | |
| 15 | + setupFiles: "./src/test/setup.ts", | |
| 16 | + css: true | |
| 17 | + } | |
| 18 | +}); |