From d3bbad09c69404705a970a8cf91e5fc05a760568 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Wed, 5 Aug 2026 09:25:59 +0100 Subject: [PATCH] Fixes Fixed an issue where media manager only shows files uploaded in specific sections. Fixed an issue with icon picker names. Removed jumbo rounding and a few other style tweaks. --- Dockerfile | 15 +- docker-compose.local.yaml | 19 +- docs/phase-10-summary.md | 372 ++++++++++++++++++ e2e/customer-journeys.spec.ts | 75 ++++ e2e/staff-journeys.spec.ts | 30 +- .../migration.sql | 19 + .../migration.sql | 31 ++ .../migration.sql | 31 ++ prisma/schema.prisma | 53 ++- prisma/seed.ts | 18 +- src/app/(customer)/appointments/[id]/page.tsx | 6 +- src/app/(customer)/home/page.tsx | 11 +- src/app/(customer)/loyalty/page.tsx | 17 +- .../settings/notifications/page.tsx | 2 +- src/app/(customer)/settings/page.tsx | 2 +- .../(owner)/owner/appointments/[id]/page.tsx | 7 +- src/app/(owner)/owner/page.tsx | 8 +- .../(owner)/owner/settings/loyalty/page.tsx | 12 +- src/app/(owner)/owner/settings/page.tsx | 6 +- src/app/(owner)/owner/team/page.tsx | 4 +- .../(staff)/staff/appointments/[id]/page.tsx | 7 +- src/app/(staff)/staff/clients/[id]/page.tsx | 3 +- src/app/(staff)/staff/clients/page.tsx | 4 +- src/app/(staff)/staff/current/page.tsx | 3 +- src/app/(staff)/staff/page.tsx | 2 +- .../staff/settings/notifications/page.tsx | 2 +- src/app/(staff)/staff/settings/page.tsx | 6 +- .../(staff)/staff/settings/profile/page.tsx | 4 +- .../media/[...path]/__tests__/route.test.ts | 65 +++ src/app/api/media/[...path]/route.ts | 41 ++ src/app/book/confirmation/page.tsx | 2 +- src/app/staff/book/page.tsx | 2 +- src/components/feedback/skeleton.tsx | 2 +- src/components/ui/card.tsx | 2 +- src/components/ui/image-gallery.tsx | 6 +- src/components/ui/loyalty-card.tsx | 88 ++++- src/components/ui/modal.tsx | 2 +- src/features/booking/actions/draft.ts | 53 ++- .../components/book-service-button.tsx | 39 ++ .../booking/components/details-form.tsx | 2 +- .../booking/components/service-selector.tsx | 2 +- src/features/booking/schemas.ts | 5 + .../components/calendar-connection-panel.tsx | 4 +- src/features/home/components/home-tabs.tsx | 15 +- .../components/install-wizard.tsx | 10 +- .../media/components/media-manager-modal.tsx | 45 ++- .../media/components/media-picker.tsx | 2 +- .../components/notification-list.tsx | 2 +- src/features/owner/actions/reviews.ts | 42 +- .../owner/components/add-staff-form.tsx | 6 +- .../owner/components/booking-rules-form.tsx | 8 +- .../components/business-details-form.tsx | 12 +- .../owner/components/catalogue-manager.tsx | 2 +- .../owner/components/loyalty-config-form.tsx | 137 ++++++- .../owner/components/opening-hours-form.tsx | 2 +- .../owner/components/reviews-manager.tsx | 4 +- .../owner/components/staff-detail-panel.tsx | 10 +- .../pwa/components/service-worker-manager.tsx | 2 +- .../pwa/components/welcome-install-card.tsx | 2 +- .../components/book-follow-up-button.tsx | 2 +- src/features/staff/components/breaks-list.tsx | 4 +- .../notification-preferences-form.tsx | 2 +- .../staff/components/profile-form.tsx | 2 +- .../staff/components/time-off-list.tsx | 6 +- .../staff/components/working-hours-form.tsx | 2 +- src/lib/icons/icon-catalog.ts | 2 - src/lib/media/__tests__/media-storage.test.ts | 98 +++++ src/lib/media/media-storage.ts | 112 +++++- .../loyalty/__tests__/loyalty-config.test.ts | 24 +- .../loyalty-owner.integration.test.ts | 14 +- .../loyalty.service.integration.test.ts | 67 +++- src/services/loyalty/loyalty.service.ts | 56 ++- src/services/loyalty/reward-threshold.ts | 38 ++ .../reporting/owner-reporting.service.ts | 3 + 74 files changed, 1622 insertions(+), 195 deletions(-) create mode 100644 docs/phase-10-summary.md create mode 100644 prisma/migrations/20260804202231_phase10_loyalty_card_and_permission_fixes/migration.sql create mode 100644 prisma/migrations/20260804202300_phase10_backfill_owner_permissions/migration.sql create mode 100644 prisma/migrations/20260804202400_phase10_rewrite_legacy_media_urls/migration.sql create mode 100644 src/app/api/media/[...path]/__tests__/route.test.ts create mode 100644 src/app/api/media/[...path]/route.ts create mode 100644 src/features/booking/components/book-service-button.tsx create mode 100644 src/lib/media/__tests__/media-storage.test.ts create mode 100644 src/services/loyalty/reward-threshold.ts diff --git a/Dockerfile b/Dockerfile index ebc544a..8464f68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -60,12 +60,15 @@ COPY --from=builder --chown=nextchair:nodejs /app/next.config.ts ./next.config.t COPY --from=builder --chown=nextchair:nodejs /app/tsconfig.json ./tsconfig.json COPY --chown=nextchair:nodejs docker/entrypoint.sh ./docker/entrypoint.sh RUN chmod +x ./docker/entrypoint.sh -# Media Manager uploads (Phase 9) — public/media is a named volume mount -# point (see docker-compose.local.yaml). Creating it here, owned by the -# non-root user, before that volume is first attached means Docker -# initializes the volume with this ownership rather than root's — see -# src/lib/media/media-storage.ts. -RUN mkdir -p ./public/media && chown nextchair:nodejs ./public/media +# Media Manager uploads (Phase 10) — storage/media (NOT public/) is a +# named volume mount point (see docker-compose.local.yaml). Served by +# src/app/api/media/[...path]/route.ts, not Next's static handling for +# public/ — verified against a real production build that runtime-added +# public/ files 404 (Next resolves public assets at build time, not from +# a live filesystem check). Creating this here, owned by the non-root +# user, before the volume first attaches means Docker initializes the +# volume with this ownership rather than root's. +RUN mkdir -p ./storage/media && chown -R nextchair:nodejs ./storage USER nextchair EXPOSE 3000 diff --git a/docker-compose.local.yaml b/docker-compose.local.yaml index eb4712b..b44a9d7 100644 --- a/docker-compose.local.yaml +++ b/docker-compose.local.yaml @@ -45,12 +45,19 @@ services: ports: - "${APP_HOST_PORT:-3000}:3000" volumes: - # Media Manager uploads (Phase 9) — served directly by Next's own - # static handling for public/, so no separate storage service is - # needed in this deployment model; this volume is what makes an - # uploaded file survive `docker compose down`/a rebuild. See - # src/lib/media/media-storage.ts. - - next-chair-media:/app/public/media + # Media Manager uploads — a dedicated volume, separate from + # next-chair-mysql-data. Phase 10 moved the mount point from + # /app/public/media to /app/storage/media (outside public/ — see + # src/lib/media/media-storage.ts's header for why runtime uploads + # can't live under public/ with this Next.js version) while + # deliberately keeping the SAME volume name, so any file already + # uploaded before this fix reappears at its new path instead of + # being orphaned. Writable by the non-root nextchair user (the + # Dockerfile pre-creates and chowns this directory before the + # volume first attaches). Persists across `docker compose restart` + # and a full image rebuild, since it's a named volume, not + # container-local storage. + - next-chair-media:/app/storage/media healthcheck: # No curl/wget in the node:22-alpine base image — a plain Node # request avoids adding a package just for this. See diff --git a/docs/phase-10-summary.md b/docs/phase-10-summary.md new file mode 100644 index 0000000..1251a35 --- /dev/null +++ b/docs/phase-10-summary.md @@ -0,0 +1,372 @@ +# Phase 10 — Post-Implementation Fixes and Loyalty Card Enhancements + +Follow-up to Phase 9: six issues found during real testing, root-caused and +fixed in dependency order, with full regression coverage and a real Docker +verification pass. This doc is the mandated 19-point summary. + +## 1. Root cause of uploaded-image 404s + +Next.js 16's `next start` (production mode) resolves `public/` asset +requests against something computed at build time, not a live filesystem +check — files present when the image was built serve `200`; files written +to `public/` at runtime (uploads) serve `404` even though they exist on +disk with correct permissions. Confirmed empirically inside the running +container (`docker exec` showed the file present and readable; the HTTP +request still 404'd). Not a permissions/volume/Docker problem — a Next.js +routing-resolution problem. + +## 2. Media delivery architecture after the fix + +- `src/lib/media/media-storage.ts` now writes to a storage root **outside** + `public/` (`storage/media` by default, overridable via + `MEDIA_STORAGE_ROOT`), resolved with traversal-safe path joining. +- A new Route Handler, `src/app/api/media/[...path]/route.ts`, reads and + streams the file itself with the correct `content-type`/cache headers, + 404s on anything missing or invalid, and rejects `..` traversal — + bypassing Next's `public/` resolution entirely. +- `storeMediaFile` verifies the file is readable back (`fs.access`) before + reporting success, so a silent write failure is never reported as a + success. +- Storage stays provider-neutral: every caller only ever sees a + `/api/media/...` URL, never a raw filesystem path, so swapping to S3 + later only touches `media-storage.ts`. +- A hand-written migration (`20260804202400_phase10_rewrite_legacy_media_urls`) + rewrites every already-stored `/media/...` URL to `/api/media/...` across + every image column in the schema, so images uploaded before this fix + keep working without a manual data fix-up. + +## 3. Docker media-volume changes + +- `Dockerfile`: creates and chowns `./storage/media` (was `./public/media`). +- `docker-compose.local.yaml`: the `next-chair-media` volume now mounts at + `/app/storage/media` — **same volume name**, deliberately, so the one + real file already uploaded to the user's business reappears at its new + path instead of being orphaned. Confirmed in this session: the pre-existing + uploaded file (`gallery/eecbe189-....jpg`) is present under + `storage/media/` and resolves `200` through `/api/media/...` after a full + image rebuild and container recreation. +- Verified end-to-end against the real persistent stack: rebuild → + recreate → migrations auto-apply → existing upload still resolves → + `/api/health` and every checked route return the expected status. + +## 4. Loyalty schema changes + +Added to `LoyaltyProgramme` (migration +`20260804202231_phase10_loyalty_card_and_permission_fixes`): + +- `rewardPosition RewardPositionMode @default(AFTER_ALL_STAMPS)` — new enum + `AFTER_ALL_STAMPS | FINAL_SLOT_IS_REWARD`. +- `cardTopRightText String?`, `cardFont LoyaltyCardFont @default(SANS)` + (enum `SANS | SERIF | MONO`), `cardTopRightTextSize LoyaltyCardTextSize + @default(MD)` (enum `SM | MD | LG`), `cardTopRightTextColor String?`, + `cardBorderColor String?`, `cardMemberNumberColor String?`. +- `CHECK (stampsRequired BETWEEN 1 AND 10)` on `stampsRequired`, confirmed + genuinely enforced on MySQL 8 (a violating `UPDATE` against the real + business's row fails with error 3819, verified in this session). +- A separate idempotent migration + (`20260804202300_phase10_backfill_owner_permissions`) backfills + `MANAGE_MEDIA_LIBRARY`/`MANAGE_REVIEWS` onto every role already holding + `MANAGE_STAFF` — see point 10. + +No destructive migration was needed: the real business's existing row had +`stampsRequired = 10`, already within range. + +## 5. Loyalty typography/colour controls + +`src/features/owner/components/loyalty-config-form.tsx` gained: + +- **Top-right card text**: plain text input + font (Sans/Serif/Mono, via + `LoyaltyCardFont` — Tailwind's built-in generic stacks only, deliberately + no new web fonts loaded) + size (Small/Medium/Large) + colour. +- Font is **card-wide** (applies to all card text), size and colour are + **specific to the top-right text only** — one clear, documented + interpretation rather than per-line font controls. +- **Border colour** and **member-number colour**, each via the existing + `ColorPicker`. +- The live preview renders through the exact same `LoyaltyCard` component + the customer sees (`src/components/ui/loyalty-card.tsx`) — no + preview-only rendering path that could drift from the real card. + +## 6. Terms-line fix + +Two independent root causes, both fixed: + +1. `getLoyaltySummary` never selected/returned `termsLine` at all — + `LoyaltySummary` didn't have the field. +2. `(customer)/loyalty/page.tsx` didn't use it anyway — it always + synthesized its own `"N more to your reward"` string and only showed it + when no reward was available, silently discarding whatever the owner + had actually configured. + +Fixed: `LoyaltySummary` now includes `termsLine`, and the page renders +`loyalty.termsLine ?? undefined` — the real configured text, wrapped safely +by the card's existing `text-center` styling, never overlapping the stamp +grid (it sits below it in the flex layout). No configured value → nothing +renders (the card's existing `{termsLine && ...}` guard), matching the +brief's "no placeholder terms" requirement. Verified via a real E2E +assertion against the seeded terms line (customer-journeys.spec.ts, journey 8). + +## 7. Stamp-limit enforcement + +`MAX_STAMPS_REQUIRED = 10`, defined once in the new +`src/services/loyalty/reward-threshold.ts` and re-exported from +`loyalty.service.ts`, enforced at every layer: + +- `validateProgrammeConfig` (domain service). +- The owner form's number input (`max={10}`, clamped on every keystroke). +- The `LoyaltyProgramme_stampsRequired_range` MySQL CHECK constraint. +- Existing integration test data already at or under 10 — no migration of + bad data was needed (checked first, per the brief's instruction). + +## 8. Reward-position mode behaviour + +New `resolveRewardThreshold()` (`src/services/loyalty/reward-threshold.ts`) +is the single place this is decided: + +- `AFTER_ALL_STAMPS` (default, matches pre-Phase-10 behaviour exactly): + threshold == `stampsRequired`. Every card position is a plain stamp. +- `FINAL_SLOT_IS_REWARD`: threshold == `stampsRequired - 1` (clamped at 1). + The last position is the reward itself, not a stamp to collect — + rendered distinctly (a `Gift` icon outline while locked, filled reward + styling once unlocked) regardless of whether `stampsRequired` was ever + literally reached. + +Every consumer reads through this one function — never re-derives its own +threshold: + +- `addStampTransaction` (shared by both the automatic completed-appointment + path and the manual staff "Add Stamp" path — same threshold, proven by + a dedicated integration test that awards one stamp each way and checks + the reward fires at the crossing point). +- `redeemReward` — deducts the threshold amount, not the raw + `stampsRequired`, so redemption in `FINAL_SLOT_IS_REWARD` mode can't + under- or over-deduct relative to what was actually collected. +- `LoyaltyCard` (customer view and owner live preview, same component). +- Every "X/Y stamps" progress display: customer Home, customer Loyalty + page, staff Current/Clients/Client-detail/Appointment-detail pages, owner + Appointment-detail page, and the batched staff customer-directory summary + (`CustomerLoyaltyBrief.rewardThreshold`) — all previously showed + `currentStamps`/`stampsRequired`, which would have under-represented + progress in `FINAL_SLOT_IS_REWARD` mode. + +The owner's "Reward Timing" control is two clearly labeled option cards +("Reward after all stamps" / "Final position is the reward"), each with +supporting copy computed from the live `stampsRequired` value, not an +unlabeled boolean. + +## 9. Google Place ID implementation + +The field already existed in the schema and the owner Reviews page +(`reviews-manager.tsx`) — the actual root cause was elsewhere (see point 10). +This session's changes: + +- Expanded the field's help text to explain how to find a Place ID via + Google Maps' own Share → Embed flow, deliberately with **no hardcoded + external URL** (per the standing "never fabricate a URL" constraint). +- `updateReviewsSettingsAction` now trims the value, detects an actual + change, and writes an audit log entry (`GOOGLE_PLACE_ID_CHANGED`, with + `previousPlaceId`/`nextPlaceId` metadata) only when it changed. +- Existing reviews are kept (not archived/deleted) when the Place ID + changes — matching the recommended, documented behaviour; the "Sync Now" + button already gave an immediate manual re-sync path. +- Validation stays basic (non-empty, trimmed) — no restrictive regex that + could reject a valid Place ID. + +## 10. Reviews-navigation bug root cause + +Not a routing bug at all. A read-only query against the real, live database +showed the installed business's Owner role was **missing** the +`MANAGE_MEDIA_LIBRARY` and `MANAGE_REVIEWS` permissions — both added to the +`Permission` enum in Phase 9, but `syncRolePermissions()` only runs at +install/seed time, so an already-installed business's `RolePermission` rows +never picked them up. The Reviews nav link's `href` and route config were +always correct; the page (and the Place ID field on it) was simply +inaccessible/hidden behind a permission check that silently failed. Fixed +with an idempotent backfill migration +(`20260804202300_phase10_backfill_owner_permissions`) rather than any +client-side workaround — confirmed on the real database in this session +(`SELECT` shows Owner now holds both permissions). + +## 11. Home service preselection approach + +`preselectServiceAction` (`src/features/booking/actions/draft.ts`): +receives only a `serviceId` from the client; the service's active/bookable +status and business are always re-read from the database. No existing +draft → creates one with the service selected. Existing draft → adds the +service if not already present (no duplicates), clearing a previously +chosen barber/date/time only if the selection actually changed — the same +rule `saveServicesStepAction` already uses. Invalid/inactive/non-bookable +service → a friendly inline error on the `BookServiceButton`, draft +untouched. No price/duration/eligibility ever crosses the URL; everything +is re-validated server-side against the signed booking-draft cookie. + +## 12. Routes/components changed + +- New: `src/app/api/media/[...path]/route.ts`, + `src/features/booking/components/book-service-button.tsx`, + `src/services/loyalty/reward-threshold.ts`. +- Rewritten: `src/lib/media/media-storage.ts`. +- Extended: `src/services/loyalty/loyalty.service.ts` (new fields on + `LoyaltySummary`/`ProgrammeConfigInput`/`CustomerLoyaltyBrief`, + `resolveRewardThreshold` re-export), `src/components/ui/loyalty-card.tsx` + (font/border/member-number/top-right-text props, reward-slot visuals), + `src/features/owner/components/loyalty-config-form.tsx` (reward-timing + selector, typography/colour controls), `src/features/booking/actions/draft.ts` + and `schemas.ts` (`preselectServiceAction`), `src/features/home/components/home-tabs.tsx` + (uses `BookServiceButton`), `src/features/owner/actions/reviews.ts` and + `reviews-manager.tsx` (Place ID audit log + help text), `Dockerfile` and + `docker-compose.local.yaml` (storage path). +- Progress-display denominator fixed (now threshold-aware, not raw + `stampsRequired`) in: `(customer)/home/page.tsx`, `(customer)/loyalty/page.tsx`, + `(staff)/staff/current/page.tsx`, `(staff)/staff/clients/page.tsx`, + `(staff)/staff/clients/[id]/page.tsx`, `(staff)/staff/appointments/[id]/page.tsx`, + `(owner)/owner/appointments/[id]/page.tsx`, `(owner)/owner/settings/loyalty/page.tsx`. + +## 13. Prisma migration + +Three migrations, all applied cleanly against the real, persistent +database in this session's Docker verification: + +1. `20260804202231_phase10_loyalty_card_and_permission_fixes` — the seven + new `LoyaltyProgramme` columns + two new enums + the stamps-required + CHECK constraint (generated via `prisma migrate dev`, CHECK + hand-appended). +2. `20260804202300_phase10_backfill_owner_permissions` — hand-written, + idempotent `INSERT IGNORE` permission backfill. +3. `20260804202400_phase10_rewrite_legacy_media_urls` — hand-written, + idempotent legacy `/media/...` → `/api/media/...` URL rewrite. + +## 14. Tests added/updated + +- Unit: `src/services/loyalty/__tests__/loyalty-config.test.ts` — 3 new + `resolveRewardThreshold` cases (both modes + the 1-stamp clamp), plus the + existing `validateProgrammeConfig` fixtures updated for the widened + `ProgrammeConfigInput`. +- Integration: `src/services/loyalty/__tests__/loyalty.service.integration.test.ts` — + 3 new cases: `FINAL_SLOT_IS_REWARD` earns via a manual add one stamp + early; the automatic and manual paths agree on the same threshold; + redemption deducts the threshold, not the raw `stampsRequired`. + `loyalty-owner.integration.test.ts` updated for the widened input type. +- E2E: `customer-journeys.spec.ts` — 2 new journeys (9: Home "Book" + preselects a service, preserves existing selections, doesn't duplicate + on re-click; 10: the same for a guest) and one new assertion on journey 8 + (the real configured terms line renders on the customer card). +- Removed: an attempted integration test that called + `preselectServiceAction` directly — it (and every other booking-draft + Server Action in this codebase) depends on `next/headers`'s + request-scoped `cookies()` and calls `redirect()` on success, neither of + which works outside a real Next.js request; this matches this codebase's + existing, documented convention (see `customer-booking.integration.test.ts`'s + own header comment) of testing these thin Server Actions via Playwright + only. The three rejection paths that don't touch `cookies()`/`redirect()` + were still directly testable, but were dropped in favour of the simpler, + already-covered E2E path once the client-bundle constraint below made + clear how easy it is for this module boundary to silently break. + +## 15. Full regression results + +- **Unit**: 253/253 passing (`npx vitest run`). +- **Integration**: 185/185 passing, against a real MySQL 8 database + (`npx vitest run --config vitest.integration.config.mts`). +- **E2E — customer-journeys.spec.ts**: 10/10 passing. +- **E2E — owner-journeys.spec.ts**: 13/13 passing (this run caught a real + bug — see point 18). +- **E2E — staff-journeys.spec.ts**: 6/10 passing. Journey 7 ("barber + changes their working hours") fails on a clean database; journeys 8–10 + didn't run as a result. Confirmed via `git status` that nothing in this + phase touches working-hours code — this is a pre-existing issue, + unrelated to any of the six fixes in this brief, and has been flagged as + its own follow-up task rather than investigated here (see point 18). +- `npx tsc --noEmit` and `npx eslint .` (whole repo): clean. +- `npm run build` (production build): succeeds. The only console noise + during the build is Prisma connection-pool timeouts while attempting to + reach the database from this sandboxed build environment (not reachable + here by design) — the build still completes and every route lists + correctly. + +## 16. Docker verification + +Performed against the user's real, persistent local stack (not a +throwaway container), in this order: + +1. `docker compose build next-chair` — image rebuilt with all Phase 10 + code. +2. `docker compose up -d next-chair` — container recreated; entrypoint + auto-applied the 3 pending migrations against the real database, + reported "Syncing Super Administrator... skipping (none configured)" + (expected — no `SUPER_ADMIN_EMAIL` set), then started successfully. +3. `curl` checks: `/api/health` → 200, `/home` → 200, `/loyalty` → 200, + `/book` → 200, `/owner/settings/loyalty` → 307 (sign-in redirect, not a + 500), `/owner/settings/reviews` → 307. No 500s anywhere. +4. The one real file already uploaded to this business's media library + (`storage/media/gallery/....jpg`) still resolves `200` with + `content-type: image/jpeg` through `/api/media/...` after the rebuild — + confirms the Phase 10.1 media fix and the volume-path change survive a + real rebuild+recreate cycle. +5. Direct `SELECT` against the real `LoyaltyProgramme` row: the new columns + are present with correct defaults (`rewardPosition = AFTER_ALL_STAMPS`, + `cardFont = SANS`, `cardTopRightTextSize = MD`) — the migration didn't + corrupt or reset the business's existing configuration. +6. A live `UPDATE ... SET stampsRequired = 15` against the real row is + rejected by the CHECK constraint (`Error 3819`) — confirms the + constraint is genuinely enforced, not just declared. +7. Direct `SELECT` against `RolePermission`: the real Owner role now holds + both `MANAGE_MEDIA_LIBRARY` and `MANAGE_REVIEWS`. + +Authenticated in-browser verification of the owner loyalty form and both +reward modes was **not** re-run through this same Docker container (curl +can't drive the sign-in form without the real owner's credentials, which +this session doesn't have and won't attempt to bypass). That coverage +comes from the Playwright E2E suite instead, run against a dev server +using the identical application code and a real MySQL 8 database — see +point 15. + +## 17. Manual verification performed + +Everything in point 16, plus the E2E suite's own real-browser, real-form, +real-database coverage of: the owner loyalty config form (typography, +colours, reward-timing selector, live preview), a customer's card +(terms line, reward-slot visuals), Home's "Book" button preselecting a +service for both a guest and a signed-in customer, and the Reviews/Place ID +settings page loading correctly for the Owner role. + +## 18. Known limitations + +- **Staff working-hours E2E failure** — pre-existing, unrelated to this + phase, flagged as its own follow-up task (spawned during this session) + rather than investigated here, per the brief's scope. +- **Duplicate `mug-saucer` icon-picker React key warning** — pre-existing, + unrelated, also flagged as its own follow-up task. +- **A real client-bundle bug was found and fixed during this phase's own + verification**: `loyalty-config-form.tsx` (a Client Component) briefly + imported `MAX_STAMPS_REQUIRED` from the full `loyalty.service.ts`, which + transitively pulls in Prisma-touching notification code — this broke the + owner loyalty settings page in a real browser (caught by the + owner-journeys E2E suite, not by `tsc`/`eslint`/unit tests, none of which + exercise the client bundle boundary). Fixed by moving both + `resolveRewardThreshold` and `MAX_STAMPS_REQUIRED` into the small, + side-effect-free `reward-threshold.ts` module client components can + import directly. Documented here because it's a real, general risk + pattern for this codebase — any future constant or pure helper that + "happens to live" in a Prisma-calling service file is one import away + from silently breaking a client bundle, and only an E2E run against a + real browser will catch it. +- Google Reviews sync itself (calling the real Google Places API) was not + exercised end-to-end — this session has no real Google API credentials, + and the brief's own "never call the Places API from every page load" + constraint means this is only reachable via the 24h cron or the manual + "Sync Now" button, neither of which this session can trigger against a + real Google account. +- The staff customer-directory's batched loyalty summary + (`CustomerLoyaltyBrief`) now shows a threshold-aware denominator like + every other progress display, but this one specific display wasn't + separately covered by a new automated test (it's a one-line change, + reusing the already-tested `resolveRewardThreshold`). + +## 19. Anything requiring real external credentials + +- A Google Cloud API key + a real Google Business Profile Place ID, to + exercise the actual Places API sync (see point 18). +- The real owner account's password, to drive an authenticated Docker + container walkthrough via `curl` rather than relying on the E2E suite's + own (seeded, non-production) credentials. diff --git a/e2e/customer-journeys.spec.ts b/e2e/customer-journeys.spec.ts index fb31a21..9d58bb4 100644 --- a/e2e/customer-journeys.spec.ts +++ b/e2e/customer-journeys.spec.ts @@ -207,5 +207,80 @@ test.describe("customer booking journeys", () => { await expect(page.getByRole("heading", { name: "Loyalty", exact: true })).toBeVisible(); await expect(page.getByText("Reward available:")).toBeVisible(); await expect(page.getByText("stamp added").first()).toBeVisible(); + + // The owner-configured terms line (see prisma/seed.ts) renders on the customer card even + // while a reward is available — previously this was silently replaced by synthesized + // "N more to your reward" text and never showed the real configured terms at all. + await expect(page.getByText("Valid for 12 months. One reward per visit.")).toBeVisible(); + }); + + test("9. clicking Book on a Home service preselects it in the booking draft", async ({ page }) => { + await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); + await page.goto("/home"); + + // The description stays visible on the Services tab row — the Book button doesn't hide it. + const skinfadeRow = page + .locator("div.flex.items-center.justify-between.gap-4.py-4") + .filter({ hasText: SEEDED.services.skinfade }); + await expect(skinfadeRow).toBeVisible(); + + await skinfadeRow.getByRole("button", { name: "Book" }).click(); + + // No existing draft — preselectServiceAction creates a fresh one with just this service, then redirects straight to /book. + await expect(page).toHaveURL(/\/book$/); + const skinfadeToggle = page.getByRole("button", { name: new RegExp(`^${SEEDED.services.skinfade}`) }); + await expect(skinfadeToggle).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByText("1 selected")).toBeVisible(); + + // A second service clicked from Home is added to the existing draft, not a replacement of it. + await page.goto("/home"); + const beardRow = page + .locator("div.flex.items-center.justify-between.gap-4.py-4") + .filter({ hasText: SEEDED.services.beardTrim }); + await beardRow.getByRole("button", { name: "Book" }).click(); + + await expect(page).toHaveURL(/\/book$/); + await expect(skinfadeToggle).toHaveAttribute("aria-pressed", "true"); + const beardToggle = page.getByRole("button", { name: new RegExp(`^${SEEDED.services.beardTrim}`) }); + await expect(beardToggle).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByText("2 selected")).toBeVisible(); + + // Re-clicking Book for a service that's already selected doesn't add a duplicate. + await page.goto("/home"); + await beardRow.getByRole("button", { name: "Book" }).click(); + await expect(page).toHaveURL(/\/book$/); + await expect(page.getByText("2 selected")).toBeVisible(); + + // Deliberately doesn't complete the booking from here — this account's appointment history is + // asserted against by name+staff in several other journeys in this file, and creating one more + // here would make those lookups ambiguous. Journey 10 (a fresh guest) completes the flow end to end. + }); + + test("10. a guest can also preselect a service from Home before booking", async ({ page }) => { + await page.goto("/home"); + // Cold Turbopack first-visit compile — see signIn()'s note in helpers.ts; a click landing before + // this settles is silently dropped, and this test (unlike the others) has no prior navigation to warm it. + await page.waitForLoadState("networkidle"); + + const haircutRow = page + .locator("div.flex.items-center.justify-between.gap-4.py-4") + .filter({ hasText: SEEDED.services.haircut }); + await haircutRow.getByRole("button", { name: "Book" }).click(); + + await expect(page).toHaveURL(/\/book$/); + await expect(page.getByRole("button", { name: new RegExp(`^${SEEDED.services.haircut}`) })).toHaveAttribute( + "aria-pressed", + "true", + ); + + await completeBookingFlow(page, { + servicesPreselected: true, + guest: { + firstName: "Preselect", + lastName: "Guest", + email: uniqueEmail("preselect-guest"), + phone: "+44 7700 900902", + }, + }); }); }); diff --git a/e2e/staff-journeys.spec.ts b/e2e/staff-journeys.spec.ts index 63a2773..7bdc07a 100644 --- a/e2e/staff-journeys.spec.ts +++ b/e2e/staff-journeys.spec.ts @@ -159,10 +159,20 @@ test.describe.serial("barber operations journeys", () => { expectedState, ); - // Restore the seeded default so this test is repeatable. + // Restore the seeded default so this test is repeatable. This save can itself surface an + // unrelated conflict — the seed's own "genuinely future" fixture for Leo (see prisma/seed.ts, + // appt-leo-future) is dated relative to whenever the seed happened to run and can + // coincidentally land on a Sunday, which is off by default — so handle either outcome rather + // than assuming a clean save (same pattern as owner-journeys.spec.ts journey 4's restore step). await page.getByRole("switch", { name: "Sunday working" }).click(); await page.getByRole("button", { name: "Save", exact: true }).click(); - await expect(page.getByText("Saved.")).toBeVisible(); + const saveAnywayButton = page.getByRole("button", { name: "Save Anyway", exact: true }); + const savedStatus = page.getByText("Saved."); + await expect(saveAnywayButton.or(savedStatus)).toBeVisible(); + if (await saveAnywayButton.isVisible()) { + await saveAnywayButton.click(); + } + await expect(savedStatus).toBeVisible(); }); test("8. barber adds a recurring break", async ({ page }) => { @@ -230,12 +240,22 @@ test.describe.serial("barber operations journeys", () => { }); test("10. barber receives a new-booking notification", async ({ page }) => { - // Journey 3 (this file runs serially) already booked Marcus a fresh appointment for Jordan - // Blake — see NotificationService/booking-notifications.ts, notifyBookingCreated, which + // Deliberately books its own fresh appointment rather than relying on journey 3's — every + // other independent journey in this file follows that same "book a fresh appointment first" + // rule (see this file's header comment), and this one was the exception, which made it fail + // in confusing ways whenever it ran outside the full serial file (e.g. `-g` filtering past + // journey 3). See NotificationService/booking-notifications.ts, notifyBookingCreated, which // queues STAFF_NEW_APPOINTMENT for the assigned barber on every booking. await signIn(page, SEEDED.owner.email, "password123"); - await page.goto("/staff"); + await page.goto("/staff/book?q=Jordan"); + await page.getByRole("button", { name: /Jordan Blake/ }).click(); + await completeStaffBookingFlow(page, { + serviceName: SEEDED.services.haircut, + staffName: SEEDED.owner.name, + }); + + await page.goto("/staff"); const bell = page.getByRole("link", { name: /Notifications, \d+ unread/ }); await expect(bell).toBeVisible(); await bell.click(); diff --git a/prisma/migrations/20260804202231_phase10_loyalty_card_and_permission_fixes/migration.sql b/prisma/migrations/20260804202231_phase10_loyalty_card_and_permission_fixes/migration.sql new file mode 100644 index 0000000..97ccdf1 --- /dev/null +++ b/prisma/migrations/20260804202231_phase10_loyalty_card_and_permission_fixes/migration.sql @@ -0,0 +1,19 @@ +-- AlterTable +ALTER TABLE `LoyaltyProgramme` ADD COLUMN `cardBorderColor` VARCHAR(191) NULL, + ADD COLUMN `cardFont` ENUM('SANS', 'SERIF', 'MONO') NOT NULL DEFAULT 'SANS', + ADD COLUMN `cardMemberNumberColor` VARCHAR(191) NULL, + ADD COLUMN `cardTopRightText` VARCHAR(191) NULL, + ADD COLUMN `cardTopRightTextColor` VARCHAR(191) NULL, + ADD COLUMN `cardTopRightTextSize` ENUM('SM', 'MD', 'LG') NOT NULL DEFAULT 'MD', + ADD COLUMN `rewardPosition` ENUM('AFTER_ALL_STAMPS', 'FINAL_SLOT_IS_REWARD') NOT NULL DEFAULT 'AFTER_ALL_STAMPS'; + +-- Stamp-limit enforcement (Phase 10 follow-up brief: "Valid range: 1–10"). +-- Confirmed via a direct read of the live database before writing this +-- migration that no existing LoyaltyProgramme row has stampsRequired > 10 +-- and no LoyaltyAccount has currentStamps > 10 — safe to add directly, +-- no data migration needed. Defense-in-depth alongside the identical +-- 1-10 check in validateProgrammeConfig() and the owner form's client +-- validation; MySQL 8.0.16+ enforces CHECK constraints (this app's +-- documented target, see schema.prisma's own header). +ALTER TABLE `LoyaltyProgramme` ADD CONSTRAINT `LoyaltyProgramme_stampsRequired_range` + CHECK (`stampsRequired` BETWEEN 1 AND 10); diff --git a/prisma/migrations/20260804202300_phase10_backfill_owner_permissions/migration.sql b/prisma/migrations/20260804202300_phase10_backfill_owner_permissions/migration.sql new file mode 100644 index 0000000..6105fb6 --- /dev/null +++ b/prisma/migrations/20260804202300_phase10_backfill_owner_permissions/migration.sql @@ -0,0 +1,31 @@ +-- Backfill MANAGE_MEDIA_LIBRARY and MANAGE_REVIEWS (added in Phase 9) +-- onto every role that already holds MANAGE_STAFF — the app's existing +-- definition of "owner" (see src/services/staff/staff.service.ts's +-- countOtherActiveManagers, "A role holding MANAGE_STAFF is this app's +-- definition of 'an owner'"). syncRolePermissions() only ever runs at +-- install/seed time, so any business installed before Phase 9 has its +-- Owner role stuck on the old 15-permission set — confirmed by reading +-- the actual RolePermission rows on a real installed business before +-- writing this migration. +-- +-- This is what silently made /owner/settings/reviews redirect away +-- (hasPermission(..., MANAGE_REVIEWS) failing, redirecting to /staff, +-- which then renders the barber calendar — looking exactly like +-- "Reviews opens Calendar") and made Media Manager deletes silently fail +-- (MANAGE_MEDIA_LIBRARY). Not a routing bug — a missing data backfill. +-- +-- Idempotent: a fresh install's Owner role already has both permissions +-- from syncRolePermissions() at install time, and INSERT IGNORE plus the +-- existing @@unique([roleId, permission]) constraint make re-running +-- this migration (or running it against an already-correct database) +-- a safe no-op. + +INSERT IGNORE INTO `RolePermission` (`id`, `roleId`, `permission`) +SELECT UUID(), owner.`roleId`, 'MANAGE_MEDIA_LIBRARY' +FROM `RolePermission` owner +WHERE owner.`permission` = 'MANAGE_STAFF'; + +INSERT IGNORE INTO `RolePermission` (`id`, `roleId`, `permission`) +SELECT UUID(), owner.`roleId`, 'MANAGE_REVIEWS' +FROM `RolePermission` owner +WHERE owner.`permission` = 'MANAGE_STAFF'; diff --git a/prisma/migrations/20260804202400_phase10_rewrite_legacy_media_urls/migration.sql b/prisma/migrations/20260804202400_phase10_rewrite_legacy_media_urls/migration.sql new file mode 100644 index 0000000..062ba39 --- /dev/null +++ b/prisma/migrations/20260804202400_phase10_rewrite_legacy_media_urls/migration.sql @@ -0,0 +1,31 @@ +-- Rewrite any already-stored `/media/...` URLs (the pre-Phase-10 upload +-- path, which never actually worked in production — Next.js 16's +-- `next start` does not dynamically serve files added to public/ after +-- the build; see src/lib/media/media-storage.ts's header for the full +-- account) to the new `/api/media/...` prefix served by +-- src/app/api/media/[...path]/route.ts. +-- +-- Confirmed via a direct read of the live database before writing this +-- migration: exactly one MediaAsset row and its matching +-- BusinessGalleryImage row use the old prefix; every other image field +-- currently stores a full external URL (Business.logoUrl/coverImageUrl, +-- etc.) and is untouched by this. Every statement below is still +-- included for completeness/safety, covers every image column that can +-- receive a MediaPicker-sourced URL, and is a no-op wherever nothing +-- matches. +-- +-- Idempotent: re-running finds nothing left matching `/media/%` (rows +-- already rewritten now start with `/api/media/%`), so this is safe to +-- apply more than once. + +UPDATE `MediaAsset` SET `url` = CONCAT('/api', `url`) WHERE `url` LIKE '/media/%'; +UPDATE `BusinessGalleryImage` SET `imageUrl` = CONCAT('/api', `imageUrl`) WHERE `imageUrl` LIKE '/media/%'; +UPDATE `Business` SET `logoUrl` = CONCAT('/api', `logoUrl`) WHERE `logoUrl` LIKE '/media/%'; +UPDATE `Business` SET `coverImageUrl` = CONCAT('/api', `coverImageUrl`) WHERE `coverImageUrl` LIKE '/media/%'; +UPDATE `Business` SET `faviconUrl` = CONCAT('/api', `faviconUrl`) WHERE `faviconUrl` LIKE '/media/%'; +UPDATE `Business` SET `appIconUrl` = CONCAT('/api', `appIconUrl`) WHERE `appIconUrl` LIKE '/media/%'; +UPDATE `Business` SET `welcomeBackgroundDesktopUrl` = CONCAT('/api', `welcomeBackgroundDesktopUrl`) WHERE `welcomeBackgroundDesktopUrl` LIKE '/media/%'; +UPDATE `Business` SET `welcomeBackgroundMobileUrl` = CONCAT('/api', `welcomeBackgroundMobileUrl`) WHERE `welcomeBackgroundMobileUrl` LIKE '/media/%'; +UPDATE `LoyaltyProgramme` SET `cardBackgroundImageUrl` = CONCAT('/api', `cardBackgroundImageUrl`) WHERE `cardBackgroundImageUrl` LIKE '/media/%'; +UPDATE `Service` SET `imageUrl` = CONCAT('/api', `imageUrl`) WHERE `imageUrl` LIKE '/media/%'; +UPDATE `User` SET `image` = CONCAT('/api', `image`) WHERE `image` LIKE '/media/%'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 79c1d5d..c120c86 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1143,14 +1143,49 @@ model AppointmentService { // count"; every stamp add/remove/redemption is its own row). // ───────────────────────────────────────────────────────────────────────── +/// Phase 10 — a small, curated set of card fonts with zero extra network +/// cost (Tailwind's own built-in generic font-family stacks), not an +/// arbitrary CSS font-family string — see loyalty-config-form.tsx's +/// IconPicker-style font Select. +enum LoyaltyCardFont { + SANS + SERIF + MONO +} + +/// Phase 10 — an enum, not a free number, so a bad value can never break +/// card layout. +enum LoyaltyCardTextSize { + SM + MD + LG +} + +/// Phase 10 — resolves the "stamps required" ambiguity the brief called +/// out: does the reward unlock only once every stamp position is filled, +/// or is the final position itself the reward (earned one stamp early)? +/// See resolveRewardThreshold() in src/services/loyalty/loyalty.service.ts +/// — the one place this is interpreted; automatic stamps, manual stamps, +/// the customer card, and progress text all read through it, so none of +/// them can disagree with each other. +enum RewardPositionMode { + AFTER_ALL_STAMPS + FINAL_SLOT_IS_REWARD +} + model LoyaltyProgramme { id String @id @default(cuid()) businessId String @unique business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) - isEnabled Boolean @default(false) - stampsRequired Int @default(10) + isEnabled Boolean @default(false) + /// Capped at 10 (see the CHECK constraint added in migration + /// 20260805000000_phase10_loyalty_card_and_permission_fixes) — enforced + /// identically in validateProgrammeConfig, the owner form, and here. + stampsRequired Int @default(10) + /// See RewardPositionMode's own doc comment. + rewardPosition RewardPositionMode @default(AFTER_ALL_STAMPS) rewardType RewardType @default(PERCENTAGE) /// Percentage points or a fixed-amount value depending on rewardType; @@ -1179,6 +1214,20 @@ model LoyaltyProgramme { stampIcon String? termsLine String? + /// Phase 10 — free text shown in the card's top-right area, above the + /// member number. Separate from the member number itself (always + /// shown, has its own colour below) — null/empty means nothing renders + /// there beyond the member number, same as today. + cardTopRightText String? + /// Card-wide — applies to all card text, not just the top-right text. + /// See LoyaltyCardFont's doc comment for why this is card-wide rather + /// than a control per element. + cardFont LoyaltyCardFont @default(SANS) + cardTopRightTextSize LoyaltyCardTextSize @default(MD) + cardTopRightTextColor String? + cardBorderColor String? + cardMemberNumberColor String? + updatedAt DateTime @updatedAt /// An empty eligibleServices/eligibleStaff list means "all services / diff --git a/prisma/seed.ts b/prisma/seed.ts index 8f6bac1..18f94d4 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -546,7 +546,19 @@ async function main() { }, }); - // Leo: checked in, about to start — Current Appointment tier 2. + // Leo: checked in today — Current Appointment tier 2 (owner-journeys.spec.ts + // journey 10 also depends on this being CHECKED_IN + "today", see below). + // Fixed at 11:00, not minutesFromNow — selectCurrentAppointment's tier-2 + // branch (appointment.service.ts) picks a CHECKED_IN row by status alone, + // never by clock time, so there was never a need to anchor this to "right + // now". Anchoring it that way was actively harmful: whenever the seed ran + // outside Leo's own 10:00-19:00 working hours (e.g. early morning), this + // appointment landed outside those hours too, which made + // findWorkingHoursConflicts (staff.service.ts) correctly-but-unhelpfully + // flag a conflict for *any* working-hours save Leo made that day — + // including ones that never touched this appointment's own weekday. A + // fixed time inside his working hours removes that time-of-day flakiness + // entirely. await prisma.appointment.upsert({ where: { id: `${business.id}-appt-today-checkedin` }, update: {}, @@ -561,8 +573,8 @@ async function main() { customerId: secondCustomer.id, staffId: leo.id, status: "CHECKED_IN", - startAt: minutesFromNow(5), - endAt: minutesFromNow(5 + haircut.durationMinutes), + startAt: daysFromNow(0, 11, 0), + endAt: daysFromNow(0, 11, haircut.durationMinutes), services: { create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }], }, diff --git a/src/app/(customer)/appointments/[id]/page.tsx b/src/app/(customer)/appointments/[id]/page.tsx index 696df26..90c2e67 100644 --- a/src/app/(customer)/appointments/[id]/page.tsx +++ b/src/app/(customer)/appointments/[id]/page.tsx @@ -157,14 +157,14 @@ export default async function AppointmentDetailsPage({
{business.phone && ( @@ -175,7 +175,7 @@ export default async function AppointmentDetailsPage({ {RESCHEDULABLE_STATUSES.has(appointment.status) && ( Reschedule diff --git a/src/app/(customer)/home/page.tsx b/src/app/(customer)/home/page.tsx index 683af9a..33ce909 100644 --- a/src/app/(customer)/home/page.tsx +++ b/src/app/(customer)/home/page.tsx @@ -14,6 +14,7 @@ import { prisma } from "@/lib/db/prisma"; import { getAvailableServices } from "@/services/scheduling/availability.service"; import { listGalleryImages } from "@/services/gallery/gallery.service"; import { getLoyaltySummary, type LoyaltySummary } from "@/services/loyalty/loyalty.service"; +import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold"; import { getBusinessReviews } from "@/services/reviews/reviews.service"; import { HomeTabs } from "@/features/home/components/home-tabs"; import type { AppointmentStatus } from "@/types/domain"; @@ -142,7 +143,7 @@ export default async function CustomerHomePage() {
{/* Hero — an owner-managed gallery (see /owner/settings/gallery), matching the content width rather than bleeding edge-to-edge. */} {heroGalleryImages.length > 0 && ( -
+
)} @@ -214,8 +215,8 @@ export default async function CustomerHomePage() { href="/loyalty" className={ loyalty.availableReward - ? "rounded-jumbo border-accent-loyalty bg-accent-loyalty/10 mb-6 block border p-5 shadow-sm" - : "rounded-jumbo border-line mb-6 block border bg-white p-5 shadow-sm" + ? "rounded-md border-accent-loyalty bg-accent-loyalty/10 mb-6 block border p-5 shadow-sm" + : "rounded-md border-line mb-6 block border bg-white p-5 shadow-sm" } > {loyalty.availableReward ? ( @@ -228,14 +229,14 @@ export default async function CustomerHomePage() {

Loyalty Progress

- {loyalty.currentStamps}/{loyalty.stampsRequired} stamps + {loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
diff --git a/src/app/(customer)/loyalty/page.tsx b/src/app/(customer)/loyalty/page.tsx index 21efc6f..2a5e410 100644 --- a/src/app/(customer)/loyalty/page.tsx +++ b/src/app/(customer)/loyalty/page.tsx @@ -10,6 +10,7 @@ import { TopNav } from "@/components/navigation/top-nav"; import { getPrimaryBusiness } from "@/lib/business/get-primary-business"; import { prisma } from "@/lib/db/prisma"; import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service"; +import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold"; export const metadata: Metadata = { title: "Loyalty" }; export const dynamic = "force-dynamic"; @@ -65,6 +66,9 @@ export default async function LoyaltyPage() { const expiresLabel = loyalty.availableReward?.expiresAt ? loyalty.availableReward.expiresAt.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" }) : null; + // The number of stamps that actually earns the reward — not always the + // same as the card's stampsRequired slot count, see resolveRewardThreshold. + const rewardThreshold = resolveRewardThreshold(loyalty); return (
@@ -77,10 +81,17 @@ export default async function LoyaltyPage() { stampsRequired={loyalty.stampsRequired} stampsEarned={loyalty.currentStamps} rewardLabel={loyalty.rewardLabel ?? "Reward"} - termsLine={loyalty.availableReward ? undefined : `${loyalty.stampsRequired - loyalty.currentStamps} more to your reward`} + termsLine={loyalty.termsLine ?? undefined} cardBackgroundColor={loyalty.cardBackgroundColor} cardBackgroundImageUrl={loyalty.cardBackgroundImageUrl} stampIcon={loyalty.stampIcon} + rewardPosition={loyalty.rewardPosition} + cardTopRightText={loyalty.cardTopRightText} + cardFont={loyalty.cardFont} + cardTopRightTextSize={loyalty.cardTopRightTextSize} + cardTopRightTextColor={loyalty.cardTopRightTextColor} + cardBorderColor={loyalty.cardBorderColor} + cardMemberNumberColor={loyalty.cardMemberNumberColor} /> {loyalty.availableReward ? ( @@ -96,13 +107,13 @@ export default async function LoyaltyPage() {

Progress

- {loyalty.currentStamps}/{loyalty.stampsRequired} stamps + {loyalty.currentStamps}/{rewardThreshold} stamps
diff --git a/src/app/(customer)/settings/notifications/page.tsx b/src/app/(customer)/settings/notifications/page.tsx index 769acd5..7e3a9de 100644 --- a/src/app/(customer)/settings/notifications/page.tsx +++ b/src/app/(customer)/settings/notifications/page.tsx @@ -31,7 +31,7 @@ export default async function CustomerNotificationSettingsPage() {
-
+

Push Notifications

diff --git a/src/app/(customer)/settings/page.tsx b/src/app/(customer)/settings/page.tsx index 49d8531..4582f71 100644 --- a/src/app/(customer)/settings/page.tsx +++ b/src/app/(customer)/settings/page.tsx @@ -65,7 +65,7 @@ export default async function CustomerSettingsPage() {
-
+

Get the app

diff --git a/src/app/(owner)/owner/appointments/[id]/page.tsx b/src/app/(owner)/owner/appointments/[id]/page.tsx index 084d3af..6628567 100644 --- a/src/app/(owner)/owner/appointments/[id]/page.tsx +++ b/src/app/(owner)/owner/appointments/[id]/page.tsx @@ -11,6 +11,7 @@ import { hasPermission } from "@/lib/auth/permissions"; import { Permission } from "@/generated/prisma/enums"; import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; import { getLoyaltySummary } from "@/services/loyalty/loyalty.service"; +import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold"; import { getAppointmentById } from "@/services/scheduling/appointment.service"; import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization"; import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions"; @@ -197,7 +198,7 @@ export default async function OwnerAppointmentDetailsPage({
Loyalty

- {loyalty.currentStamps}/{loyalty.stampsRequired} stamps + {loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps {loyalty.availableReward && · Reward available}

{loyaltyTransactions.length > 0 && ( @@ -231,14 +232,14 @@ export default async function OwnerAppointmentDetailsPage({
{RESCHEDULABLE.has(appointment.status) && ( Reschedule / Reassign diff --git a/src/app/(owner)/owner/page.tsx b/src/app/(owner)/owner/page.tsx index bffb33a..6fc9783 100644 --- a/src/app/(owner)/owner/page.tsx +++ b/src/app/(owner)/owner/page.tsx @@ -67,7 +67,7 @@ export default async function OwnerDashboardPage() {