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.
This commit is contained in:
74 files changed
+1612
-185
No files matched your search
+9
-6
@@ -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 --from=builder --chown=nextchair:nodejs /app/tsconfig.json ./tsconfig.json
|
||||||
COPY --chown=nextchair:nodejs docker/entrypoint.sh ./docker/entrypoint.sh
|
COPY --chown=nextchair:nodejs docker/entrypoint.sh ./docker/entrypoint.sh
|
||||||
RUN chmod +x ./docker/entrypoint.sh
|
RUN chmod +x ./docker/entrypoint.sh
|
||||||
# Media Manager uploads (Phase 9) — public/media is a named volume mount
|
# Media Manager uploads (Phase 10) — storage/media (NOT public/) is a
|
||||||
# point (see docker-compose.local.yaml). Creating it here, owned by the
|
# named volume mount point (see docker-compose.local.yaml). Served by
|
||||||
# non-root user, before that volume is first attached means Docker
|
# src/app/api/media/[...path]/route.ts, not Next's static handling for
|
||||||
# initializes the volume with this ownership rather than root's — see
|
# public/ — verified against a real production build that runtime-added
|
||||||
# src/lib/media/media-storage.ts.
|
# public/ files 404 (Next resolves public assets at build time, not from
|
||||||
RUN mkdir -p ./public/media && chown nextchair:nodejs ./public/media
|
# 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
|
USER nextchair
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -45,12 +45,19 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "${APP_HOST_PORT:-3000}:3000"
|
- "${APP_HOST_PORT:-3000}:3000"
|
||||||
volumes:
|
volumes:
|
||||||
# Media Manager uploads (Phase 9) — served directly by Next's own
|
# Media Manager uploads — a dedicated volume, separate from
|
||||||
# static handling for public/, so no separate storage service is
|
# next-chair-mysql-data. Phase 10 moved the mount point from
|
||||||
# needed in this deployment model; this volume is what makes an
|
# /app/public/media to /app/storage/media (outside public/ — see
|
||||||
# uploaded file survive `docker compose down`/a rebuild. See
|
# src/lib/media/media-storage.ts's header for why runtime uploads
|
||||||
# src/lib/media/media-storage.ts.
|
# can't live under public/ with this Next.js version) while
|
||||||
- next-chair-media:/app/public/media
|
# 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:
|
healthcheck:
|
||||||
# No curl/wget in the node:22-alpine base image — a plain Node
|
# No curl/wget in the node:22-alpine base image — a plain Node
|
||||||
# request avoids adding a package just for this. See
|
# request avoids adding a package just for this. See
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -207,5 +207,80 @@ test.describe("customer booking journeys", () => {
|
|||||||
await expect(page.getByRole("heading", { name: "Loyalty", exact: true })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Loyalty", exact: true })).toBeVisible();
|
||||||
await expect(page.getByText("Reward available:")).toBeVisible();
|
await expect(page.getByText("Reward available:")).toBeVisible();
|
||||||
await expect(page.getByText("stamp added").first()).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",
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -159,10 +159,20 @@ test.describe.serial("barber operations journeys", () => {
|
|||||||
expectedState,
|
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("switch", { name: "Sunday working" }).click();
|
||||||
await page.getByRole("button", { name: "Save", exact: true }).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 }) => {
|
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 }) => {
|
test("10. barber receives a new-booking notification", async ({ page }) => {
|
||||||
// Journey 3 (this file runs serially) already booked Marcus a fresh appointment for Jordan
|
// Deliberately books its own fresh appointment rather than relying on journey 3's — every
|
||||||
// Blake — see NotificationService/booking-notifications.ts, notifyBookingCreated, which
|
// 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.
|
// queues STAFF_NEW_APPOINTMENT for the assigned barber on every booking.
|
||||||
await signIn(page, SEEDED.owner.email, "password123");
|
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/ });
|
const bell = page.getByRole("link", { name: /Notifications, \d+ unread/ });
|
||||||
await expect(bell).toBeVisible();
|
await expect(bell).toBeVisible();
|
||||||
await bell.click();
|
await bell.click();
|
||||||
|
|||||||
+19
@@ -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);
|
||||||
@@ -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';
|
||||||
@@ -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/%';
|
||||||
@@ -1143,6 +1143,36 @@ model AppointmentService {
|
|||||||
// count"; every stamp add/remove/redemption is its own row).
|
// 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 {
|
model LoyaltyProgramme {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|
||||||
@@ -1150,7 +1180,12 @@ model LoyaltyProgramme {
|
|||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
isEnabled Boolean @default(false)
|
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)
|
stampsRequired Int @default(10)
|
||||||
|
/// See RewardPositionMode's own doc comment.
|
||||||
|
rewardPosition RewardPositionMode @default(AFTER_ALL_STAMPS)
|
||||||
|
|
||||||
rewardType RewardType @default(PERCENTAGE)
|
rewardType RewardType @default(PERCENTAGE)
|
||||||
/// Percentage points or a fixed-amount value depending on rewardType;
|
/// Percentage points or a fixed-amount value depending on rewardType;
|
||||||
@@ -1179,6 +1214,20 @@ model LoyaltyProgramme {
|
|||||||
stampIcon String?
|
stampIcon String?
|
||||||
termsLine 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
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
/// An empty eligibleServices/eligibleStaff list means "all services /
|
/// An empty eligibleServices/eligibleStaff list means "all services /
|
||||||
|
|||||||
+15
-3
@@ -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({
|
await prisma.appointment.upsert({
|
||||||
where: { id: `${business.id}-appt-today-checkedin` },
|
where: { id: `${business.id}-appt-today-checkedin` },
|
||||||
update: {},
|
update: {},
|
||||||
@@ -561,8 +573,8 @@ async function main() {
|
|||||||
customerId: secondCustomer.id,
|
customerId: secondCustomer.id,
|
||||||
staffId: leo.id,
|
staffId: leo.id,
|
||||||
status: "CHECKED_IN",
|
status: "CHECKED_IN",
|
||||||
startAt: minutesFromNow(5),
|
startAt: daysFromNow(0, 11, 0),
|
||||||
endAt: minutesFromNow(5 + haircut.durationMinutes),
|
endAt: daysFromNow(0, 11, haircut.durationMinutes),
|
||||||
services: {
|
services: {
|
||||||
create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }],
|
create: [{ serviceId: haircut.id, priceAtBooking: haircut.price, durationAtBookingMinutes: haircut.durationMinutes }],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -157,14 +157,14 @@ export default async function AppointmentDetailsPage({
|
|||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<a
|
<a
|
||||||
href={`/api/appointments/${appointment.id}/calendar.ics${token ? `?token=${token}` : ""}`}
|
href={`/api/appointments/${appointment.id}/calendar.ics${token ? `?token=${token}` : ""}`}
|
||||||
className="rounded-jumbo border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
||||||
>
|
>
|
||||||
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
|
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
|
||||||
</a>
|
</a>
|
||||||
{business.phone && (
|
{business.phone && (
|
||||||
<a
|
<a
|
||||||
href={`tel:${business.phone}`}
|
href={`tel:${business.phone}`}
|
||||||
className="rounded-jumbo border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
||||||
>
|
>
|
||||||
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Contact Business
|
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Contact Business
|
||||||
</a>
|
</a>
|
||||||
@@ -175,7 +175,7 @@ export default async function AppointmentDetailsPage({
|
|||||||
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
|
{RESCHEDULABLE_STATUSES.has(appointment.status) && (
|
||||||
<Link
|
<Link
|
||||||
href={`/appointments/${appointment.id}/reschedule${token ? `?token=${token}` : ""}`}
|
href={`/appointments/${appointment.id}/reschedule${token ? `?token=${token}` : ""}`}
|
||||||
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
|
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
|
||||||
>
|
>
|
||||||
Reschedule
|
Reschedule
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { prisma } from "@/lib/db/prisma";
|
|||||||
import { getAvailableServices } from "@/services/scheduling/availability.service";
|
import { getAvailableServices } from "@/services/scheduling/availability.service";
|
||||||
import { listGalleryImages } from "@/services/gallery/gallery.service";
|
import { listGalleryImages } from "@/services/gallery/gallery.service";
|
||||||
import { getLoyaltySummary, type LoyaltySummary } from "@/services/loyalty/loyalty.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 { getBusinessReviews } from "@/services/reviews/reviews.service";
|
||||||
import { HomeTabs } from "@/features/home/components/home-tabs";
|
import { HomeTabs } from "@/features/home/components/home-tabs";
|
||||||
import type { AppointmentStatus } from "@/types/domain";
|
import type { AppointmentStatus } from "@/types/domain";
|
||||||
@@ -142,7 +143,7 @@ export default async function CustomerHomePage() {
|
|||||||
<div>
|
<div>
|
||||||
{/* Hero — an owner-managed gallery (see /owner/settings/gallery), matching the content width rather than bleeding edge-to-edge. */}
|
{/* Hero — an owner-managed gallery (see /owner/settings/gallery), matching the content width rather than bleeding edge-to-edge. */}
|
||||||
{heroGalleryImages.length > 0 && (
|
{heroGalleryImages.length > 0 && (
|
||||||
<div className="mx-auto max-w-2xl px-6 pt-4">
|
<div className="mx-auto max-w-2xl px-6 pt-4 bg-white">
|
||||||
<ImageGallery images={heroGalleryImages} />
|
<ImageGallery images={heroGalleryImages} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -214,8 +215,8 @@ export default async function CustomerHomePage() {
|
|||||||
href="/loyalty"
|
href="/loyalty"
|
||||||
className={
|
className={
|
||||||
loyalty.availableReward
|
loyalty.availableReward
|
||||||
? "rounded-jumbo border-accent-loyalty bg-accent-loyalty/10 mb-6 block border p-5 shadow-sm"
|
? "rounded-md 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-line mb-6 block border bg-white p-5 shadow-sm"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{loyalty.availableReward ? (
|
{loyalty.availableReward ? (
|
||||||
@@ -228,14 +229,14 @@ export default async function CustomerHomePage() {
|
|||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<p className="text-sm font-bold">Loyalty Progress</p>
|
<p className="text-sm font-bold">Loyalty Progress</p>
|
||||||
<span className="text-muted text-xs">
|
<span className="text-muted text-xs">
|
||||||
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
|
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-stone h-2 w-full overflow-hidden rounded-full">
|
<div className="bg-stone h-2 w-full overflow-hidden rounded-full">
|
||||||
<div
|
<div
|
||||||
className="bg-accent-loyalty h-full rounded-full"
|
className="bg-accent-loyalty h-full rounded-full"
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.min(100, (loyalty.currentStamps / loyalty.stampsRequired) * 100)}%`,
|
width: `${Math.min(100, (loyalty.currentStamps / resolveRewardThreshold(loyalty)) * 100)}%`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { TopNav } from "@/components/navigation/top-nav";
|
|||||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||||
|
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Loyalty" };
|
export const metadata: Metadata = { title: "Loyalty" };
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -65,6 +66,9 @@ export default async function LoyaltyPage() {
|
|||||||
const expiresLabel = loyalty.availableReward?.expiresAt
|
const expiresLabel = loyalty.availableReward?.expiresAt
|
||||||
? loyalty.availableReward.expiresAt.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })
|
? loyalty.availableReward.expiresAt.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })
|
||||||
: null;
|
: 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 (
|
return (
|
||||||
<div className="pb-12">
|
<div className="pb-12">
|
||||||
@@ -77,10 +81,17 @@ export default async function LoyaltyPage() {
|
|||||||
stampsRequired={loyalty.stampsRequired}
|
stampsRequired={loyalty.stampsRequired}
|
||||||
stampsEarned={loyalty.currentStamps}
|
stampsEarned={loyalty.currentStamps}
|
||||||
rewardLabel={loyalty.rewardLabel ?? "Reward"}
|
rewardLabel={loyalty.rewardLabel ?? "Reward"}
|
||||||
termsLine={loyalty.availableReward ? undefined : `${loyalty.stampsRequired - loyalty.currentStamps} more to your reward`}
|
termsLine={loyalty.termsLine ?? undefined}
|
||||||
cardBackgroundColor={loyalty.cardBackgroundColor}
|
cardBackgroundColor={loyalty.cardBackgroundColor}
|
||||||
cardBackgroundImageUrl={loyalty.cardBackgroundImageUrl}
|
cardBackgroundImageUrl={loyalty.cardBackgroundImageUrl}
|
||||||
stampIcon={loyalty.stampIcon}
|
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 ? (
|
{loyalty.availableReward ? (
|
||||||
@@ -96,13 +107,13 @@ export default async function LoyaltyPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm font-bold">Progress</p>
|
<p className="text-sm font-bold">Progress</p>
|
||||||
<span className="text-muted text-xs">
|
<span className="text-muted text-xs">
|
||||||
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
|
{loyalty.currentStamps}/{rewardThreshold} stamps
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-stone h-2 w-full overflow-hidden rounded-full">
|
<div className="bg-stone h-2 w-full overflow-hidden rounded-full">
|
||||||
<div
|
<div
|
||||||
className="bg-accent-loyalty h-full rounded-full"
|
className="bg-accent-loyalty h-full rounded-full"
|
||||||
style={{ width: `${Math.min(100, (loyalty.currentStamps / loyalty.stampsRequired) * 100)}%` }}
|
style={{ width: `${Math.min(100, (loyalty.currentStamps / rewardThreshold) * 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export default async function CustomerNotificationSettingsPage() {
|
|||||||
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
||||||
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
||||||
<NotificationPreferencesForm initial={cells} action={saveCustomerNotificationPreferencesAction} />
|
<NotificationPreferencesForm initial={cells} action={saveCustomerNotificationPreferencesAction} />
|
||||||
<div className="rounded-jumbo border-line border bg-white p-5">
|
<div className="rounded-md border-line border bg-white p-5">
|
||||||
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
|
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
|
||||||
<PushSubscribeToggle />
|
<PushSubscribeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default async function CustomerSettingsPage() {
|
|||||||
<Bell className="h-4 w-4" aria-hidden="true" /> Notifications
|
<Bell className="h-4 w-4" aria-hidden="true" /> Notifications
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-line rounded-jumbo border bg-white p-5">
|
<div className="border-line rounded-md border bg-white p-5">
|
||||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||||
<InstallAppButton businessName={branding.name} />
|
<InstallAppButton businessName={branding.name} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { hasPermission } from "@/lib/auth/permissions";
|
|||||||
import { Permission } from "@/generated/prisma/enums";
|
import { Permission } from "@/generated/prisma/enums";
|
||||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||||
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||||
|
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||||
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
||||||
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
|
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
|
||||||
@@ -197,7 +198,7 @@ export default async function OwnerAppointmentDetailsPage({
|
|||||||
<div className="border-line border-t pt-4">
|
<div className="border-line border-t pt-4">
|
||||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Loyalty</span>
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">Loyalty</span>
|
||||||
<p className="text-sm font-bold">
|
<p className="text-sm font-bold">
|
||||||
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
|
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
|
||||||
{loyalty.availableReward && <span className="text-accent-loyalty"> · Reward available</span>}
|
{loyalty.availableReward && <span className="text-accent-loyalty"> · Reward available</span>}
|
||||||
</p>
|
</p>
|
||||||
{loyaltyTransactions.length > 0 && (
|
{loyaltyTransactions.length > 0 && (
|
||||||
@@ -231,14 +232,14 @@ export default async function OwnerAppointmentDetailsPage({
|
|||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<a
|
<a
|
||||||
href={`/api/appointments/${appointment.id}/calendar.ics`}
|
href={`/api/appointments/${appointment.id}/calendar.ics`}
|
||||||
className="rounded-jumbo border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
||||||
>
|
>
|
||||||
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
|
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
|
||||||
</a>
|
</a>
|
||||||
{RESCHEDULABLE.has(appointment.status) && (
|
{RESCHEDULABLE.has(appointment.status) && (
|
||||||
<Link
|
<Link
|
||||||
href={`/owner/appointments/${appointment.id}/reschedule`}
|
href={`/owner/appointments/${appointment.id}/reschedule`}
|
||||||
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
|
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
|
||||||
>
|
>
|
||||||
Reschedule / Reassign
|
Reschedule / Reassign
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default async function OwnerDashboardPage() {
|
|||||||
<div
|
<div
|
||||||
key={`${w.code}-${i}`}
|
key={`${w.code}-${i}`}
|
||||||
role="alert"
|
role="alert"
|
||||||
className="bg-warning/10 text-warning-foreground border-warning/30 flex items-start gap-3 rounded-jumbo border p-4 text-sm"
|
className="bg-warning/10 text-warning-foreground border-warning/30 flex items-start gap-3 rounded-md border p-4 text-sm"
|
||||||
>
|
>
|
||||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
<p>{w.message}</p>
|
<p>{w.message}</p>
|
||||||
@@ -171,19 +171,19 @@ export default async function OwnerDashboardPage() {
|
|||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Link
|
<Link
|
||||||
href="/owner/reports"
|
href="/owner/reports"
|
||||||
className="rounded-jumbo border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
|
className="rounded-md border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
|
||||||
>
|
>
|
||||||
View Reports
|
View Reports
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/owner/team"
|
href="/owner/team"
|
||||||
className="rounded-jumbo border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
|
className="rounded-md border-line flex-1 border bg-white px-5 py-4 text-center text-sm font-bold"
|
||||||
>
|
>
|
||||||
Manage Team
|
Manage Team
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/owner/settings/booking-rules"
|
href="/owner/settings/booking-rules"
|
||||||
className="rounded-jumbo bg-ink flex-1 px-5 py-4 text-center text-sm font-bold text-white"
|
className="rounded-md bg-ink flex-1 px-5 py-4 text-center text-sm font-bold text-white"
|
||||||
>
|
>
|
||||||
Update Booking Rules
|
Update Booking Rules
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { auth } from "@/auth";
|
|||||||
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { hasPermission } from "@/lib/auth/permissions";
|
import { hasPermission } from "@/lib/auth/permissions";
|
||||||
import { Permission } from "@/generated/prisma/enums";
|
import { LoyaltyCardFont, LoyaltyCardTextSize, Permission, RewardPositionMode } from "@/generated/prisma/enums";
|
||||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||||
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
|
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
|
||||||
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
|
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
|
||||||
@@ -35,6 +35,7 @@ export default async function OwnerLoyaltyConfigPage() {
|
|||||||
? {
|
? {
|
||||||
isEnabled: programme.isEnabled,
|
isEnabled: programme.isEnabled,
|
||||||
stampsRequired: programme.stampsRequired,
|
stampsRequired: programme.stampsRequired,
|
||||||
|
rewardPosition: programme.rewardPosition,
|
||||||
rewardType: programme.rewardType,
|
rewardType: programme.rewardType,
|
||||||
rewardValue: programme.rewardValue?.toString(),
|
rewardValue: programme.rewardValue?.toString(),
|
||||||
rewardLabel: programme.rewardLabel ?? undefined,
|
rewardLabel: programme.rewardLabel ?? undefined,
|
||||||
@@ -50,15 +51,24 @@ export default async function OwnerLoyaltyConfigPage() {
|
|||||||
cardBackgroundImageUrl: programme.cardBackgroundImageUrl ?? undefined,
|
cardBackgroundImageUrl: programme.cardBackgroundImageUrl ?? undefined,
|
||||||
stampIcon: programme.stampIcon ?? undefined,
|
stampIcon: programme.stampIcon ?? undefined,
|
||||||
termsLine: programme.termsLine ?? undefined,
|
termsLine: programme.termsLine ?? undefined,
|
||||||
|
cardTopRightText: programme.cardTopRightText ?? undefined,
|
||||||
|
cardFont: programme.cardFont,
|
||||||
|
cardTopRightTextSize: programme.cardTopRightTextSize,
|
||||||
|
cardTopRightTextColor: programme.cardTopRightTextColor ?? undefined,
|
||||||
|
cardBorderColor: programme.cardBorderColor ?? undefined,
|
||||||
|
cardMemberNumberColor: programme.cardMemberNumberColor ?? undefined,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
isEnabled: false,
|
isEnabled: false,
|
||||||
stampsRequired: 10,
|
stampsRequired: 10,
|
||||||
|
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||||
rewardType: "PERCENTAGE" as const,
|
rewardType: "PERCENTAGE" as const,
|
||||||
autoApplyReward: true,
|
autoApplyReward: true,
|
||||||
requiresStaffApproval: false,
|
requiresStaffApproval: false,
|
||||||
earnOnCancelled: false,
|
earnOnCancelled: false,
|
||||||
earnOnNoShow: false,
|
earnOnNoShow: false,
|
||||||
|
cardFont: LoyaltyCardFont.SANS,
|
||||||
|
cardTopRightTextSize: LoyaltyCardTextSize.MD,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -38,13 +38,13 @@ export default async function OwnerSettingsPage() {
|
|||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/staff/settings"
|
href="/staff/settings"
|
||||||
className="rounded-jumbo border-line flex items-center gap-3 border bg-white p-5 text-sm font-bold hover:bg-stone"
|
className="rounded-md border-line flex items-center gap-3 border bg-white p-5 text-sm font-bold hover:bg-stone"
|
||||||
>
|
>
|
||||||
<ScissorsSquare className="text-accent h-5 w-5" aria-hidden="true" />
|
<ScissorsSquare className="text-accent h-5 w-5" aria-hidden="true" />
|
||||||
Switch to Barber Tools
|
Switch to Barber Tools
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<ul className="divide-line rounded-jumbo border-line divide-y border bg-white">
|
<ul className="divide-line rounded-md border-line divide-y border bg-white">
|
||||||
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
|
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
|
||||||
<li key={href}>
|
<li key={href}>
|
||||||
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
|
<Link href={href} className="hover:bg-stone flex items-center gap-3 px-5 py-4 text-sm font-bold">
|
||||||
@@ -55,7 +55,7 @@ export default async function OwnerSettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="rounded-jumbo border-line border bg-white p-5">
|
<div className="rounded-md border-line border bg-white p-5">
|
||||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||||
<InstallAppButton businessName={branding.name} />
|
<InstallAppButton businessName={branding.name} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default async function OwnerTeamPage({
|
|||||||
|
|
||||||
<div className="grid gap-3 lg:grid-cols-2">
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
{staff.length === 0 ? (
|
{staff.length === 0 ? (
|
||||||
<p className="text-muted col-span-full rounded-jumbo border border-line bg-white p-5 text-center text-sm">
|
<p className="text-muted col-span-full rounded-md border border-line bg-white p-5 text-center text-sm">
|
||||||
No staff match “{q}”.
|
No staff match “{q}”.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -69,7 +69,7 @@ export default async function OwnerTeamPage({
|
|||||||
key={s.id}
|
key={s.id}
|
||||||
href={`/owner/team/${s.id}`}
|
href={`/owner/team/${s.id}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-jumbo border-line flex items-center gap-4 border bg-white p-5",
|
"rounded-md border-line flex items-center gap-4 border bg-white p-5",
|
||||||
s.status === "INACTIVE" && "opacity-60",
|
s.status === "INACTIVE" && "opacity-60",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { TopNav } from "@/components/navigation/top-nav";
|
|||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||||
|
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||||
import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
import { assertOwnAppointment, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
||||||
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
|
import { AppointmentStatusActions } from "@/features/staff/components/appointment-status-actions";
|
||||||
@@ -216,7 +217,7 @@ export default async function StaffAppointmentDetailsPage({
|
|||||||
Loyalty
|
Loyalty
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm font-bold">
|
<p className="text-sm font-bold">
|
||||||
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
|
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
|
||||||
{loyalty.availableReward && (
|
{loyalty.availableReward && (
|
||||||
<span className="text-accent-loyalty"> · Reward available</span>
|
<span className="text-accent-loyalty"> · Reward available</span>
|
||||||
)}
|
)}
|
||||||
@@ -257,14 +258,14 @@ export default async function StaffAppointmentDetailsPage({
|
|||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<a
|
<a
|
||||||
href={`/api/appointments/${appointment.id}/calendar.ics`}
|
href={`/api/appointments/${appointment.id}/calendar.ics`}
|
||||||
className="rounded-jumbo border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
className="rounded-md border-line flex items-center gap-2 border bg-white px-5 py-3 text-sm font-bold"
|
||||||
>
|
>
|
||||||
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
|
<CalendarPlus className="text-accent h-4 w-4" aria-hidden="true" /> Add to Calendar
|
||||||
</a>
|
</a>
|
||||||
{RESCHEDULABLE.has(appointment.status) && (
|
{RESCHEDULABLE.has(appointment.status) && (
|
||||||
<Link
|
<Link
|
||||||
href={`/staff/appointments/${appointment.id}/reschedule`}
|
href={`/staff/appointments/${appointment.id}/reschedule`}
|
||||||
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
|
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
|
||||||
>
|
>
|
||||||
Reschedule
|
Reschedule
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { TopNav } from "@/components/navigation/top-nav";
|
|||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
import { getLoyaltyHistory, getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||||
|
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||||
import { listAppointments } from "@/services/scheduling/appointment.service";
|
import { listAppointments } from "@/services/scheduling/appointment.service";
|
||||||
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
|
import { AddStampButton } from "@/features/staff/components/add-stamp-button";
|
||||||
import { CorrectStampButton } from "@/features/staff/components/correct-stamp-button";
|
import { CorrectStampButton } from "@/features/staff/components/correct-stamp-button";
|
||||||
@@ -201,7 +202,7 @@ export default async function StaffCustomerDetailsPage({
|
|||||||
Loyalty
|
Loyalty
|
||||||
</span>
|
</span>
|
||||||
<p className="mb-3 text-sm font-bold">
|
<p className="mb-3 text-sm font-bold">
|
||||||
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
|
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
|
||||||
{loyalty.availableReward && (
|
{loyalty.availableReward && (
|
||||||
<span className="text-accent-loyalty"> · Reward available</span>
|
<span className="text-accent-loyalty"> · Reward available</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default async function StaffClientsPage({
|
|||||||
<Link
|
<Link
|
||||||
key={customer.id}
|
key={customer.id}
|
||||||
href={`/staff/clients/${customer.id}`}
|
href={`/staff/clients/${customer.id}`}
|
||||||
className="rounded-jumbo border-line hover:bg-stone flex items-center gap-3 border bg-white px-5 py-4"
|
className="rounded-md border-line hover:bg-stone flex items-center gap-3 border bg-white px-5 py-4"
|
||||||
>
|
>
|
||||||
<div className="bg-stone text-muted flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold">
|
<div className="bg-stone text-muted flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-sm font-bold">
|
||||||
{customer.firstName.charAt(0)}
|
{customer.firstName.charAt(0)}
|
||||||
@@ -157,7 +157,7 @@ export default async function StaffClientsPage({
|
|||||||
{loyalty && (
|
{loyalty && (
|
||||||
<span className={loyalty.hasAvailableReward ? "text-accent-loyalty" : ""}>
|
<span className={loyalty.hasAvailableReward ? "text-accent-loyalty" : ""}>
|
||||||
{" "}
|
{" "}
|
||||||
· {loyalty.currentStamps}/{loyalty.stampsRequired} stamps
|
· {loyalty.currentStamps}/{loyalty.rewardThreshold} stamps
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { prisma } from "@/lib/db/prisma";
|
|||||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
import { todayIn } from "@/lib/time/business-time";
|
import { todayIn } from "@/lib/time/business-time";
|
||||||
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||||
|
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||||
import {
|
import {
|
||||||
listAppointments,
|
listAppointments,
|
||||||
selectCurrentAppointment,
|
selectCurrentAppointment,
|
||||||
@@ -187,7 +188,7 @@ export default async function StaffCurrentPage() {
|
|||||||
Loyalty
|
Loyalty
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm font-bold">
|
<p className="text-sm font-bold">
|
||||||
{loyalty.currentStamps}/{loyalty.stampsRequired} stamps
|
{loyalty.currentStamps}/{resolveRewardThreshold(loyalty)} stamps
|
||||||
{loyalty.availableReward && (
|
{loyalty.availableReward && (
|
||||||
<span className="text-accent-loyalty"> · Reward available</span>
|
<span className="text-accent-loyalty"> · Reward available</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export default async function StaffCalendarPage({
|
|||||||
appointment alongside the calendar instead of requiring a
|
appointment alongside the calendar instead of requiring a
|
||||||
separate tab, since there's room for both at once. */}
|
separate tab, since there's room for both at once. */}
|
||||||
<aside className="hidden shrink-0 lg:block lg:w-80">
|
<aside className="hidden shrink-0 lg:block lg:w-80">
|
||||||
<div className="rounded-jumbo border-line sticky top-6 border bg-white p-5">
|
<div className="rounded-md border-line sticky top-6 border bg-white p-5">
|
||||||
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
<span className="text-muted block text-[10px] font-bold tracking-widest uppercase">
|
||||||
Right Now
|
Right Now
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default async function StaffNotificationsPage() {
|
|||||||
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
<TopNav title="Notifications" onBack sticky className="bg-stone" />
|
||||||
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
<main className="mx-auto max-w-2xl space-y-6 px-6 py-6">
|
||||||
<NotificationPreferencesForm initial={cells} action={saveNotificationPreferencesAction} />
|
<NotificationPreferencesForm initial={cells} action={saveNotificationPreferencesAction} />
|
||||||
<div className="rounded-jumbo border-line border bg-white p-5">
|
<div className="rounded-md border-line border bg-white p-5">
|
||||||
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
|
<h2 className="mb-3 text-sm font-bold">Push Notifications</h2>
|
||||||
<PushSubscribeToggle />
|
<PushSubscribeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,14 +40,14 @@ export default async function StaffSettingsPage() {
|
|||||||
{canManageStaff && (
|
{canManageStaff && (
|
||||||
<Link
|
<Link
|
||||||
href="/owner"
|
href="/owner"
|
||||||
className="flex items-center gap-3 rounded-jumbo border border-line bg-white p-5 text-sm font-bold hover:bg-stone"
|
className="flex items-center gap-3 rounded-md border border-line bg-white p-5 text-sm font-bold hover:bg-stone"
|
||||||
>
|
>
|
||||||
<ShieldCheck className="h-5 w-5 text-accent" aria-hidden="true" />
|
<ShieldCheck className="h-5 w-5 text-accent" aria-hidden="true" />
|
||||||
Switch to Owner Admin
|
Switch to Owner Admin
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
|
<ul className="divide-y divide-line rounded-md border border-line bg-white">
|
||||||
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
|
{SETTINGS_LINKS.map(({ href, label, icon: Icon }) => (
|
||||||
<li key={href}>
|
<li key={href}>
|
||||||
<Link href={href} className="flex items-center gap-3 px-5 py-4 text-sm font-bold hover:bg-stone">
|
<Link href={href} className="flex items-center gap-3 px-5 py-4 text-sm font-bold hover:bg-stone">
|
||||||
@@ -58,7 +58,7 @@ export default async function StaffSettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="rounded-jumbo border border-line bg-white p-5">
|
<div className="rounded-md border border-line bg-white p-5">
|
||||||
<p className="mb-3 text-sm font-bold">Get the app</p>
|
<p className="mb-3 text-sm font-bold">Get the app</p>
|
||||||
<InstallAppButton businessName={branding.name} />
|
<InstallAppButton businessName={branding.name} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,11 +35,11 @@ export default async function StaffProfilePage() {
|
|||||||
Services You Offer
|
Services You Offer
|
||||||
</p>
|
</p>
|
||||||
{profile.services.length === 0 ? (
|
{profile.services.length === 0 ? (
|
||||||
<p className="text-muted rounded-jumbo border border-line bg-white p-5 text-center text-sm">
|
<p className="text-muted rounded-md border border-line bg-white p-5 text-center text-sm">
|
||||||
No services assigned yet. Ask the owner to add you to a service.
|
No services assigned yet. Ask the owner to add you to a service.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
|
<ul className="divide-y divide-line rounded-md border border-line bg-white">
|
||||||
{profile.services.map((s) => (
|
{profile.services.map((s) => (
|
||||||
<li key={s.service.id} className="px-5 py-3 text-sm font-bold">
|
<li key={s.service.id} className="px-5 py-3 text-sm font-bold">
|
||||||
{s.service.name}
|
{s.service.name}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/** Exercises the actual Route Handler function, not just the storage helper it wraps — real temp-dir I/O, same MEDIA_STORAGE_ROOT override as media-storage.test.ts. */
|
||||||
|
describe("GET /api/media/[...path]", () => {
|
||||||
|
let tempRoot: string;
|
||||||
|
let storage: typeof import("@/lib/media/media-storage");
|
||||||
|
let route: typeof import("../route");
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempRoot = await mkdtemp(path.join(tmpdir(), "nextchair-media-route-test-"));
|
||||||
|
process.env.MEDIA_STORAGE_ROOT = tempRoot;
|
||||||
|
storage = await import("@/lib/media/media-storage");
|
||||||
|
route = await import("../route");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.MEDIA_STORAGE_ROOT;
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const PNG_BYTES = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
|
||||||
|
it("returns 200 with the correct content type and bytes for a stored file", async () => {
|
||||||
|
const stored = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "branding" });
|
||||||
|
const segments = stored.url.replace(storage.MEDIA_URL_PREFIX, "").split("/");
|
||||||
|
|
||||||
|
const response = await route.GET(new Request(`http://localhost${stored.url}`), { params: Promise.resolve({ path: segments }) });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("content-type")).toBe("image/png");
|
||||||
|
expect(response.headers.get("cache-control")).toContain("immutable");
|
||||||
|
const body = Buffer.from(await response.arrayBuffer());
|
||||||
|
expect(body.equals(PNG_BYTES)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 for a file that doesn't exist", async () => {
|
||||||
|
const response = await route.GET(new Request("http://localhost/api/media/branding/missing.png"), {
|
||||||
|
params: Promise.resolve({ path: ["branding", "missing.png"] }),
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 (not the file) for a path-traversal attempt", async () => {
|
||||||
|
const response = await route.GET(new Request("http://localhost/api/media/..%2F..%2Fpackage.json"), {
|
||||||
|
params: Promise.resolve({ path: ["..", "..", "package.json"] }),
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 for a deleted file — it no longer resolves", async () => {
|
||||||
|
const stored = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "gallery" });
|
||||||
|
const segments = stored.url.replace(storage.MEDIA_URL_PREFIX, "").split("/");
|
||||||
|
|
||||||
|
await storage.deleteMediaFile(stored.url);
|
||||||
|
|
||||||
|
const response = await route.GET(new Request(`http://localhost${stored.url}`), { params: Promise.resolve({ path: segments }) });
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { readStoredMediaFile } from "@/lib/media/media-storage";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves uploaded media files directly — see media-storage.ts's header
|
||||||
|
* for why this route exists at all (Next's own `public/` static handling
|
||||||
|
* does not serve files added after the build, verified against a real
|
||||||
|
* production container). This is the only public entry point for
|
||||||
|
* uploaded media; nothing else touches the filesystem to serve one.
|
||||||
|
*
|
||||||
|
* Deliberately unauthenticated — every image this ever serves (business
|
||||||
|
* logo, gallery/portfolio, staff/service photos, loyalty card
|
||||||
|
* background) is already customer-facing public data, same as the
|
||||||
|
* `public/` assets it replaces. Path safety (not access control) is the
|
||||||
|
* only thing this route needs to get right — see
|
||||||
|
* resolveMediaFilePath/readStoredMediaFile for the traversal guard.
|
||||||
|
*
|
||||||
|
* `/api/*` is already excluded from src/proxy.ts's matcher, so this
|
||||||
|
* route is reachable before installation completes and without a
|
||||||
|
* session — necessary, since branding previews and the installer itself
|
||||||
|
* may reference these URLs.
|
||||||
|
*/
|
||||||
|
export async function GET(_request: Request, context: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path: segments } = await context.params;
|
||||||
|
const file = await readStoredMediaFile(segments);
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: "Not found." }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(new Uint8Array(file.buffer), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": file.mimeType,
|
||||||
|
// Safe to cache indefinitely — each URL's filename is a random
|
||||||
|
// id assigned once at upload time; a given URL's content never
|
||||||
|
// changes in place (delete + re-upload always gets a new URL).
|
||||||
|
"Cache-Control": "public, max-age=31536000, immutable",
|
||||||
|
"Content-Length": String(file.buffer.byteLength),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ export default async function BookConfirmationPage() {
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
href={`/api/appointments/${appointment.id}/calendar.ics${reference.guestAccessToken ? `?token=${reference.guestAccessToken}` : ""}`}
|
href={`/api/appointments/${appointment.id}/calendar.ics${reference.guestAccessToken ? `?token=${reference.guestAccessToken}` : ""}`}
|
||||||
className="flex w-full items-center justify-center gap-2 rounded-jumbo border border-line bg-white py-4 text-sm font-bold"
|
className="flex w-full items-center justify-center gap-2 rounded-md border border-line bg-white py-4 text-sm font-bold"
|
||||||
>
|
>
|
||||||
<CalendarPlus className="h-4 w-4 text-accent" aria-hidden="true" /> Add to Calendar
|
<CalendarPlus className="h-4 w-4 text-accent" aria-hidden="true" /> Add to Calendar
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default async function StaffBookCustomerPage({
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{q && (
|
{q && (
|
||||||
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
|
<div className="divide-line rounded-md border-line divide-y border bg-white">
|
||||||
{results.length === 0 ? (
|
{results.length === 0 ? (
|
||||||
<p className="text-muted px-5 py-4 text-sm">
|
<p className="text-muted px-5 py-4 text-sm">
|
||||||
No customers match “{q}”.
|
No customers match “{q}”.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export function Skeleton({ className }: { className?: string }) {
|
|||||||
|
|
||||||
export function CardSkeleton() {
|
export function CardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-jumbo border border-line bg-white p-5">
|
<div className="rounded-md border border-line bg-white p-5">
|
||||||
<div className="mb-4 flex items-center gap-3">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
<Skeleton className="h-10 w-10 rounded-full" />
|
<Skeleton className="h-10 w-10 rounded-full" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils/cn";
|
|||||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("rounded-jumbo border border-line bg-white shadow-sm", className)}
|
className={cn("rounded-md border border-line bg-white shadow-sm", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function ImageGallery({ images, className }: ImageGalleryProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("w-full", className)}>
|
<div className={cn("w-full", className)}>
|
||||||
<div className="bg-stone relative aspect-[16/9] w-full overflow-hidden rounded-jumbo">
|
<div className="bg-stone relative aspect-[16/9] w-full overflow-hidden rounded-md">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
|
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
|
||||||
<img src={active.url} alt={active.caption ?? ""} className="h-full w-full object-cover" />
|
<img src={active.url} alt={active.caption ?? ""} className="h-full w-full object-cover" />
|
||||||
{images.length > 1 && (
|
{images.length > 1 && (
|
||||||
@@ -36,7 +36,7 @@ export function ImageGallery({ images, className }: ImageGalleryProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => goTo(activeIndex - 1)}
|
onClick={() => goTo(activeIndex - 1)}
|
||||||
aria-label="Previous image"
|
aria-label="Previous image"
|
||||||
className="bg-ink/60 hover:bg-ink/80 absolute top-1/2 left-3 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-white"
|
className="bg-ink/60 hover:bg-ink/80 absolute top-1/2 left-3 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md text-white"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
@@ -44,7 +44,7 @@ export function ImageGallery({ images, className }: ImageGalleryProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => goTo(activeIndex + 1)}
|
onClick={() => goTo(activeIndex + 1)}
|
||||||
aria-label="Next image"
|
aria-label="Next image"
|
||||||
className="bg-ink/60 hover:bg-ink/80 absolute top-1/2 right-3 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full text-white"
|
className="bg-ink/60 hover:bg-ink/80 absolute top-1/2 right-3 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md text-white"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { Gift } from "lucide-react";
|
||||||
|
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
|
||||||
import { findCatalogIcon } from "@/lib/icons/icon-catalog";
|
import { findCatalogIcon } from "@/lib/icons/icon-catalog";
|
||||||
import { cn } from "@/lib/utils/cn";
|
import { cn } from "@/lib/utils/cn";
|
||||||
|
import { resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||||
|
|
||||||
|
const FONT_CLASSES: Record<LoyaltyCardFont, string> = {
|
||||||
|
[LoyaltyCardFont.SANS]: "font-sans",
|
||||||
|
[LoyaltyCardFont.SERIF]: "font-serif",
|
||||||
|
[LoyaltyCardFont.MONO]: "font-mono",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOP_RIGHT_TEXT_SIZE_CLASSES: Record<LoyaltyCardTextSize, string> = {
|
||||||
|
[LoyaltyCardTextSize.SM]: "text-[8px]",
|
||||||
|
[LoyaltyCardTextSize.MD]: "text-[10px]",
|
||||||
|
[LoyaltyCardTextSize.LG]: "text-[13px]",
|
||||||
|
};
|
||||||
|
|
||||||
export interface LoyaltyCardProps {
|
export interface LoyaltyCardProps {
|
||||||
businessName: string;
|
businessName: string;
|
||||||
@@ -15,6 +30,14 @@ export interface LoyaltyCardProps {
|
|||||||
cardBackgroundColor?: string | null;
|
cardBackgroundColor?: string | null;
|
||||||
cardBackgroundImageUrl?: string | null;
|
cardBackgroundImageUrl?: string | null;
|
||||||
stampIcon?: string | null;
|
stampIcon?: string | null;
|
||||||
|
/** See resolveRewardThreshold's doc comment — determines whether the final stamp position renders as a normal stamp or as the reward slot itself. */
|
||||||
|
rewardPosition?: RewardPositionMode;
|
||||||
|
cardTopRightText?: string | null;
|
||||||
|
cardFont?: LoyaltyCardFont;
|
||||||
|
cardTopRightTextSize?: LoyaltyCardTextSize;
|
||||||
|
cardTopRightTextColor?: string | null;
|
||||||
|
cardBorderColor?: string | null;
|
||||||
|
cardMemberNumberColor?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,13 +62,34 @@ export function LoyaltyCard({
|
|||||||
cardBackgroundColor,
|
cardBackgroundColor,
|
||||||
cardBackgroundImageUrl,
|
cardBackgroundImageUrl,
|
||||||
stampIcon,
|
stampIcon,
|
||||||
|
rewardPosition = RewardPositionMode.AFTER_ALL_STAMPS,
|
||||||
|
cardTopRightText,
|
||||||
|
cardFont = LoyaltyCardFont.SANS,
|
||||||
|
cardTopRightTextSize = LoyaltyCardTextSize.MD,
|
||||||
|
cardTopRightTextColor,
|
||||||
|
cardBorderColor,
|
||||||
|
cardMemberNumberColor,
|
||||||
}: LoyaltyCardProps) {
|
}: LoyaltyCardProps) {
|
||||||
const icon = findCatalogIcon(stampIcon);
|
const icon = findCatalogIcon(stampIcon);
|
||||||
|
const threshold = resolveRewardThreshold({ stampsRequired, rewardPosition });
|
||||||
|
// Only FINAL_SLOT_IS_REWARD sets aside a dedicated slot — under
|
||||||
|
// AFTER_ALL_STAMPS every position is a plain stamp and the reward is
|
||||||
|
// shown separately (see the "Reward available" banner on the loyalty
|
||||||
|
// page), not by any one slot.
|
||||||
|
const rewardSlotIndex = rewardPosition === RewardPositionMode.FINAL_SLOT_IS_REWARD ? stampsRequired - 1 : null;
|
||||||
|
const borderColor = cardBorderColor || "var(--color-accent-loyalty)";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="ring-accent-loyalty relative w-full overflow-hidden rounded-2xl shadow-[0_30px_60px_rgba(0,0,0,0.45)] ring-[3px]"
|
className={cn(
|
||||||
style={{ aspectRatio: "1.65 / 1", backgroundColor: cardBackgroundColor || "#141414" }}
|
"relative w-full overflow-hidden rounded-2xl",
|
||||||
|
FONT_CLASSES[cardFont],
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
aspectRatio: "1.65 / 1",
|
||||||
|
backgroundColor: cardBackgroundColor || "#141414",
|
||||||
|
boxShadow: `0 30px 60px rgba(0,0,0,0.45), 0 0 0 3px ${borderColor}`,
|
||||||
|
}}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={`${businessName} loyalty card, ${stampsEarned} of ${stampsRequired} stamps`}
|
aria-label={`${businessName} loyalty card, ${stampsEarned} of ${stampsRequired} stamps`}
|
||||||
>
|
>
|
||||||
@@ -66,33 +110,49 @@ export function LoyaltyCard({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative flex h-full flex-col justify-between p-4">
|
<div className="relative flex h-full flex-col justify-between p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h2 className="text-lg font-black italic tracking-tight text-white">Loyalty Card</h2>
|
<h2 className="text-lg font-black italic tracking-tight text-white">Loyalty Card</h2>
|
||||||
<span className="border-accent-loyalty/30 text-accent-loyalty rounded border px-2 py-0.5 text-[8px] font-bold tracking-widest uppercase">
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
{cardTopRightText && (
|
||||||
|
<span
|
||||||
|
className={cn(TOP_RIGHT_TEXT_SIZE_CLASSES[cardTopRightTextSize], "text-right font-bold text-white")}
|
||||||
|
style={{ color: cardTopRightTextColor || undefined }}
|
||||||
|
>
|
||||||
|
{cardTopRightText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="border-accent-loyalty/30 text-accent-loyalty rounded border px-2 py-0.5 text-[8px] font-bold tracking-widest uppercase"
|
||||||
|
style={{ color: cardMemberNumberColor || undefined, borderColor: cardMemberNumberColor ? `${cardMemberNumberColor}4d` : undefined }}
|
||||||
|
>
|
||||||
Member #{memberNumber}
|
Member #{memberNumber}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-5 gap-2">
|
<div className="grid grid-cols-5 gap-2">
|
||||||
{Array.from({ length: stampsRequired }, (_, i) => {
|
{Array.from({ length: stampsRequired }, (_, i) => {
|
||||||
const earned = i < stampsEarned;
|
const isRewardSlot = i === rewardSlotIndex;
|
||||||
// Only the final slot gets reward styling, and only once it's
|
const earned = isRewardSlot ? stampsEarned >= threshold : i < stampsEarned;
|
||||||
// actually been earned — otherwise an in-progress card (e.g.
|
|
||||||
// 3/10 stamps) would show the reward label on slot 10 already.
|
|
||||||
const isRewardSlot = earned && i === stampsRequired - 1;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex aspect-square items-center justify-center rounded-full",
|
"flex aspect-square items-center justify-center rounded-full",
|
||||||
isRewardSlot
|
isRewardSlot
|
||||||
|
? earned
|
||||||
? "bg-accent-loyalty scale-[1.08] shadow-lg"
|
? "bg-accent-loyalty scale-[1.08] shadow-lg"
|
||||||
|
: "border-accent-loyalty/50 bg-accent-loyalty/10 border-2 border-dashed"
|
||||||
: earned
|
: earned
|
||||||
? "bg-white shadow-inner"
|
? "bg-white shadow-inner"
|
||||||
: "border-2 border-dashed border-white/20 bg-white/10",
|
: "border-2 border-dashed border-white/20 bg-white/10",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isRewardSlot ? (
|
{isRewardSlot ? (
|
||||||
|
earned ? (
|
||||||
<span className="text-ink text-center text-[7px] leading-tight font-black">{rewardLabel}</span>
|
<span className="text-ink text-center text-[7px] leading-tight font-black">{rewardLabel}</span>
|
||||||
|
) : (
|
||||||
|
<Gift className="text-accent-loyalty h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
)
|
||||||
) : earned ? (
|
) : earned ? (
|
||||||
<FontAwesomeIcon icon={icon} className="text-ink h-3.5 w-3.5" />
|
<FontAwesomeIcon icon={icon} className="text-ink h-3.5 w-3.5" />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -101,7 +161,7 @@ export function LoyaltyCard({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{termsLine && (
|
{termsLine && (
|
||||||
<p className="text-center text-[7px] font-bold tracking-widest text-white/40 uppercase">{termsLine}</p>
|
<p className="text-center text-[7px] mt-2 font-bold tracking-widest text-white/40 uppercase">{termsLine}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export function Modal({ open, onClose, title, children, className }: ModalProps)
|
|||||||
}}
|
}}
|
||||||
aria-labelledby="modal-title"
|
aria-labelledby="modal-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"m-auto w-[calc(100%-2rem)] max-w-md rounded-jumbo border border-line bg-white p-0 shadow-xl backdrop:bg-ink/40 backdrop:backdrop-blur-sm",
|
"m-auto w-[calc(100%-2rem)] max-w-md rounded-md border border-line bg-white p-0 shadow-xl backdrop:bg-ink/40 backdrop:backdrop-blur-sm",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
|||||||
import { validateBookingSlot } from "@/services/scheduling/availability.service";
|
import { validateBookingSlot } from "@/services/scheduling/availability.service";
|
||||||
import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization";
|
import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization";
|
||||||
import { getFirstFriendlyMessage } from "../error-messages";
|
import { getFirstFriendlyMessage } from "../error-messages";
|
||||||
import { saveBarberTimeStepSchema, saveDetailsStepSchema, saveServicesStepSchema, startRebookSchema } from "../schemas";
|
import {
|
||||||
|
preselectServiceSchema,
|
||||||
|
saveBarberTimeStepSchema,
|
||||||
|
saveDetailsStepSchema,
|
||||||
|
saveServicesStepSchema,
|
||||||
|
startRebookSchema,
|
||||||
|
} from "../schemas";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step-by-step booking draft actions. Each one: parses input, calls a
|
* Step-by-step booking draft actions. Each one: parses input, calls a
|
||||||
@@ -49,6 +55,51 @@ export async function saveServicesStepAction(input: unknown): Promise<StepAction
|
|||||||
redirect("/book/barber-time");
|
redirect("/book/barber-time");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home's Services tab "Book" button (see BookServiceButton) — adds one
|
||||||
|
* service to the existing booking draft and sends the customer straight
|
||||||
|
* to /book with it already selected, per the follow-up brief's "Home
|
||||||
|
* Service Book Preselection" requirement. Only ever receives a service
|
||||||
|
* id from the client; everything else (does it exist, is it active, is
|
||||||
|
* it online-bookable, which business it belongs to) is re-read from the
|
||||||
|
* database here, never trusted from the caller — same discipline as
|
||||||
|
* every other draft action in this file.
|
||||||
|
*
|
||||||
|
* Reuses the exact same "only clear staff/date/time if the service
|
||||||
|
* selection actually changed" rule saveServicesStepAction already uses
|
||||||
|
* above, rather than inventing a second one.
|
||||||
|
*/
|
||||||
|
export async function preselectServiceAction(
|
||||||
|
input: unknown,
|
||||||
|
): Promise<{ success: false; error: string } | never> {
|
||||||
|
const parsed = preselectServiceSchema.safeParse(input);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, error: "That service couldn't be selected." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = await prisma.service.findUnique({ where: { id: parsed.data.serviceId } });
|
||||||
|
if (!service || !service.isActive || !service.allowOnlineBooking) {
|
||||||
|
return { success: false, error: "This service isn't available for online booking right now." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft = await readDraft(service.businessId);
|
||||||
|
const alreadySelected = draft.serviceIds.includes(service.id);
|
||||||
|
const nextServiceIds = alreadySelected ? draft.serviceIds : [...draft.serviceIds, service.id];
|
||||||
|
|
||||||
|
await writeDraft({
|
||||||
|
...draft,
|
||||||
|
businessId: service.businessId,
|
||||||
|
serviceIds: nextServiceIds,
|
||||||
|
// Adding a genuinely new service can change total duration/eligible
|
||||||
|
// barbers, invalidating a previously chosen slot — clear it. Re-clicking
|
||||||
|
// Book for a service that's already selected changes nothing, so leave
|
||||||
|
// any in-progress barber/date/time alone.
|
||||||
|
...(alreadySelected ? {} : { staffId: undefined, date: undefined, startAt: undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect("/book");
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveBarberTimeStepAction(input: unknown): Promise<StepActionResult> {
|
export async function saveBarberTimeStepAction(input: unknown): Promise<StepActionResult> {
|
||||||
const parsed = saveBarberTimeStepSchema.safeParse(input);
|
const parsed = saveBarberTimeStepSchema.safeParse(input);
|
||||||
if (!parsed.success) return { success: false, error: "Please choose a valid time." };
|
if (!parsed.success) return { success: false, error: "Please choose a valid time." };
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { preselectServiceAction } from "../actions/draft";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home's Services tab "Book" button — preselects this service in the
|
||||||
|
* booking draft (see preselectServiceAction) before sending the customer
|
||||||
|
* to /book, instead of the plain `<Link href="/book">` it used to be.
|
||||||
|
* `preselectServiceAction` redirects on success; this only ever has
|
||||||
|
* something to render when it instead returns a friendly error (an
|
||||||
|
* inactive/removed service, or one no longer online-bookable).
|
||||||
|
*/
|
||||||
|
export function BookServiceButton({ serviceId }: { serviceId: string }) {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await preselectServiceAction({ serviceId });
|
||||||
|
if (result && !result.success) setError(result.error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<Button type="button" size="sm" isLoading={isPending} onClick={handleClick}>
|
||||||
|
Book
|
||||||
|
</Button>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-danger max-w-[10rem] text-right text-[10px] font-medium">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -106,7 +106,7 @@ export function DetailsForm(props: {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isAuthenticated && prefill && (
|
{isAuthenticated && prefill && (
|
||||||
<div className="rounded-jumbo border-line border bg-white p-5">
|
<div className="rounded-md border-line border bg-white p-5">
|
||||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Booking as</p>
|
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Booking as</p>
|
||||||
<p className="mt-1 text-sm font-bold">
|
<p className="mt-1 text-sm font-bold">
|
||||||
{prefill.firstName} {prefill.lastName}
|
{prefill.firstName} {prefill.lastName}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function ServiceSelector(props: {
|
|||||||
{grouped.map((group) => (
|
{grouped.map((group) => (
|
||||||
<section key={group.id}>
|
<section key={group.id}>
|
||||||
<h2 className="mb-3 text-sm font-bold">{group.name}</h2>
|
<h2 className="mb-3 text-sm font-bold">{group.name}</h2>
|
||||||
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
|
<div className="divide-line rounded-md border-line divide-y border bg-white">
|
||||||
{group.services.map((service) => {
|
{group.services.map((service) => {
|
||||||
const selected = selectedIds.includes(service.id);
|
const selected = selectedIds.includes(service.id);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export const getAvailableServicesSchema = z.object({
|
|||||||
staffId: z.string().optional(),
|
staffId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Home's Services tab "Book" button — see preselectServiceAction. Only a service id ever crosses this boundary; price/duration/eligibility are always re-read from the database server-side, never trusted from the client. */
|
||||||
|
export const preselectServiceSchema = z.object({
|
||||||
|
serviceId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
export const getAvailableBarbersSchema = z.object({
|
export const getAvailableBarbersSchema = z.object({
|
||||||
businessId: z.string(),
|
businessId: z.string(),
|
||||||
serviceIds: z.array(z.string()).min(1),
|
serviceIds: z.array(z.string()).min(1),
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
|
|||||||
|
|
||||||
if (!state.connected) {
|
if (!state.connected) {
|
||||||
return (
|
return (
|
||||||
<div className="border-line rounded-jumbo border bg-white p-5">
|
<div className="border-line rounded-md border bg-white p-5">
|
||||||
<p className="mb-1 text-sm font-bold">Google Calendar</p>
|
<p className="mb-1 text-sm font-bold">Google Calendar</p>
|
||||||
<p className="text-muted mb-4 text-xs">
|
<p className="text-muted mb-4 text-xs">
|
||||||
Sync your confirmed appointments to your own Google Calendar. This connects a Google account separately
|
Sync your confirmed appointments to your own Google Calendar. This connects a Google account separately
|
||||||
@@ -85,7 +85,7 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-line rounded-jumbo border bg-white p-5">
|
<div className="border-line rounded-md border bg-white p-5">
|
||||||
<p className="mb-1 text-sm font-bold">Google Calendar</p>
|
<p className="mb-1 text-sm font-bold">Google Calendar</p>
|
||||||
<div className="text-muted mb-4 space-y-1 text-xs">
|
<div className="text-muted mb-4 space-y-1 text-xs">
|
||||||
<p>Connected as {state.accountEmail}</p>
|
<p>Connected as {state.accountEmail}</p>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { AtSign, Globe, Mail, Phone, Search, Share2, Star } from "lucide-react";
|
import { AtSign, Globe, Mail, Phone, Search, Share2, Star } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { BarberCard } from "@/components/ui/barber-card";
|
import { BarberCard } from "@/components/ui/barber-card";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { ServiceCard } from "@/components/ui/service-card";
|
import { ServiceCard } from "@/components/ui/service-card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { BookServiceButton } from "@/features/booking/components/book-service-button";
|
||||||
import { cn } from "@/lib/utils/cn";
|
import { cn } from "@/lib/utils/cn";
|
||||||
import type { AvailableService } from "@/services/scheduling/availability.service";
|
import type { AvailableService } from "@/services/scheduling/availability.service";
|
||||||
import type { BusinessReviewSummary } from "@/services/reviews/reviews.service";
|
import type { BusinessReviewSummary } from "@/services/reviews/reviews.service";
|
||||||
@@ -40,7 +39,7 @@ export interface HomeTabsProps {
|
|||||||
export function HomeTabs(props: HomeTabsProps) {
|
export function HomeTabs(props: HomeTabsProps) {
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue="services">
|
<Tabs defaultValue="services">
|
||||||
<TabsList className="bg-stone border-line sticky top-0 z-30 -mx-6 rounded-none border-b px-6 py-3">
|
<TabsList className="bg-white border-t border-b border-line sticky top-0 z-30 -mx-6 rounded-none px-6 py-3">
|
||||||
<TabsTrigger value="services">Services</TabsTrigger>
|
<TabsTrigger value="services">Services</TabsTrigger>
|
||||||
<TabsTrigger value="portfolio">Portfolio</TabsTrigger>
|
<TabsTrigger value="portfolio">Portfolio</TabsTrigger>
|
||||||
<TabsTrigger value="reviews">Reviews</TabsTrigger>
|
<TabsTrigger value="reviews">Reviews</TabsTrigger>
|
||||||
@@ -165,11 +164,7 @@ function ServicesTab({
|
|||||||
durationMinutes: service.durationMinutes,
|
durationMinutes: service.durationMinutes,
|
||||||
formattedPrice: `£${Number(service.price).toFixed(2)}`,
|
formattedPrice: `£${Number(service.price).toFixed(2)}`,
|
||||||
}}
|
}}
|
||||||
action={
|
action={<BookServiceButton serviceId={service.id} />}
|
||||||
<Button asChild size="sm">
|
|
||||||
<Link href="/book">Book</Link>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -289,7 +284,7 @@ function DetailsTab({
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{mapEmbedSrc && (
|
{mapEmbedSrc && (
|
||||||
<section>
|
<section>
|
||||||
<div className="bg-stone aspect-[16/9] w-full overflow-hidden rounded-jumbo">
|
<div className="bg-stone aspect-[16/9] w-full overflow-hidden rounded-md">
|
||||||
<iframe
|
<iframe
|
||||||
src={mapEmbedSrc}
|
src={mapEmbedSrc}
|
||||||
title="Business location map"
|
title="Business location map"
|
||||||
@@ -336,7 +331,7 @@ function DetailsTab({
|
|||||||
{(hasSocial || phone || email) && (
|
{(hasSocial || phone || email) && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Social & Contact</h3>
|
<h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Social & Contact</h3>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{phone && (
|
{phone && (
|
||||||
<a href={`tel:${phone}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
|
<a href={`tel:${phone}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
|
||||||
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Call
|
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Call
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ export function InstallWizard() {
|
|||||||
Copy Monday to all days
|
Copy Monday to all days
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
|
<div className="divide-line rounded-md border-line divide-y border bg-white">
|
||||||
{hours.map((day) => (
|
{hours.map((day) => (
|
||||||
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
|
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -427,7 +427,7 @@ export function InstallWizard() {
|
|||||||
{step === 6 && (
|
{step === 6 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-xl font-bold">Booking settings</h2>
|
<h2 className="text-xl font-bold">Booking settings</h2>
|
||||||
<div className="rounded-jumbo border-line border bg-white p-5">
|
<div className="rounded-md border-line border bg-white p-5">
|
||||||
<Switch
|
<Switch
|
||||||
checked={bookingRules.allowGuestBooking}
|
checked={bookingRules.allowGuestBooking}
|
||||||
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, allowGuestBooking: checked })}
|
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, allowGuestBooking: checked })}
|
||||||
@@ -467,7 +467,7 @@ export function InstallWizard() {
|
|||||||
onChange={(e) => setBookingRules({ ...bookingRules, slotIntervalMinutes: Number(e.target.value) })}
|
onChange={(e) => setBookingRules({ ...bookingRules, slotIntervalMinutes: Number(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-jumbo border-line space-y-3 border bg-white p-5">
|
<div className="rounded-md border-line space-y-3 border bg-white p-5">
|
||||||
<Switch
|
<Switch
|
||||||
checked={bookingRules.customerCanChooseStaff}
|
checked={bookingRules.customerCanChooseStaff}
|
||||||
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, customerCanChooseStaff: checked })}
|
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, customerCanChooseStaff: checked })}
|
||||||
@@ -509,7 +509,7 @@ export function InstallWizard() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{starterServices.map((s) => (
|
{starterServices.map((s) => (
|
||||||
<div key={s.key} className="rounded-jumbo border-line space-y-3 border bg-white p-4">
|
<div key={s.key} className="rounded-md border-line space-y-3 border bg-white p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Service</p>
|
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Service</p>
|
||||||
<button type="button" onClick={() => removeStarter(s.key)} className="text-danger text-xs font-bold uppercase">
|
<button type="button" onClick={() => removeStarter(s.key)} className="text-danger text-xs font-bold uppercase">
|
||||||
@@ -590,7 +590,7 @@ function StepNav({ onBack, onNext, nextDisabled }: { onBack: () => void; onNext:
|
|||||||
|
|
||||||
function ReviewSection({ title, children }: { title: string; children: React.ReactNode }) {
|
function ReviewSection({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-jumbo border-line border bg-white p-4">
|
<div className="rounded-md border-line border bg-white p-4">
|
||||||
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">{title}</p>
|
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">{title}</p>
|
||||||
<p className="mt-1 text-sm">{children}</p>
|
<p className="mt-1 text-sm">{children}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface MediaManagerModalProps {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelect: (url: string) => void;
|
onSelect: (url: string) => void;
|
||||||
/** Tags new uploads and pre-filters the Library tab — e.g. "branding", "loyalty", "staff", "service", "portfolio", "welcome". */
|
/** Tags new uploads for this field — e.g. "branding", "loyalty", "staff", "service", "portfolio", "welcome". The Library tab is one shared pool across every field by default; this only narrows it down if "This Field Only" is picked. */
|
||||||
category: string;
|
category: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,12 +29,21 @@ export interface MediaManagerModalProps {
|
|||||||
* docs/phase-9-summary.md. Upload / Library / External URL, all in one
|
* docs/phase-9-summary.md. Upload / Library / External URL, all in one
|
||||||
* dialog, backed by src/features/media/actions/media.ts (Server Actions,
|
* dialog, backed by src/features/media/actions/media.ts (Server Actions,
|
||||||
* not a REST route — see that file's header for why).
|
* not a REST route — see that file's header for why).
|
||||||
|
*
|
||||||
|
* The Library is a single, business-wide pool: an image uploaded from the
|
||||||
|
* Gallery manager shows up when picking a logo, and vice versa — "category"
|
||||||
|
* only tags where an upload came from and lets the "This Field Only" filter
|
||||||
|
* narrow the view, it never hides an image from other fields by default.
|
||||||
*/
|
*/
|
||||||
export function MediaManagerModal({ open, onClose, onSelect, category }: MediaManagerModalProps) {
|
export function MediaManagerModal({ open, onClose, onSelect, category }: MediaManagerModalProps) {
|
||||||
const [tab, setTab] = useState("library");
|
const [tab, setTab] = useState("library");
|
||||||
const [assets, setAssets] = useState<MediaAssetSummary[]>([]);
|
const [assets, setAssets] = useState<MediaAssetSummary[]>([]);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [scope, setScope] = useState<"category" | "all">("category");
|
// Defaults to "all" — the library is one shared pool across every image
|
||||||
|
// field (logo, cover, gallery, staff/service photos, ...), so an image
|
||||||
|
// uploaded anywhere should be visible everywhere by default. "category"
|
||||||
|
// is an opt-in narrowing filter, not the default view.
|
||||||
|
const [scope, setScope] = useState<"all" | "category">("all");
|
||||||
const [selectedAsset, setSelectedAsset] = useState<MediaAssetSummary | null>(null);
|
const [selectedAsset, setSelectedAsset] = useState<MediaAssetSummary | null>(null);
|
||||||
const [externalUrl, setExternalUrl] = useState("");
|
const [externalUrl, setExternalUrl] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -97,8 +106,8 @@ export function MediaManagerModal({ open, onClose, onSelect, category }: MediaMa
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<TabsContent value="library">
|
<TabsContent value="library">
|
||||||
<div className="mb-4 flex items-center gap-2">
|
<div className="mb-4 space-y-3">
|
||||||
<div className="relative flex-1">
|
<div className="relative">
|
||||||
<Search className="text-muted pointer-events-none absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
|
<Search className="text-muted pointer-events-none absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
@@ -108,13 +117,27 @@ export function MediaManagerModal({ open, onClose, onSelect, category }: MediaMa
|
|||||||
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-2.5 pr-4 pl-11 text-sm outline-none focus:ring-2"
|
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-2.5 pr-4 pl-11 text-sm outline-none focus:ring-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ value: "all", label: "All Images" },
|
||||||
|
{ value: "category", label: "This Field Only" },
|
||||||
|
] as const
|
||||||
|
).map((option) => (
|
||||||
<button
|
<button
|
||||||
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setScope((s) => (s === "category" ? "all" : "category"))}
|
onClick={() => setScope(option.value)}
|
||||||
className="border-line shrink-0 rounded-xl border bg-white px-3 py-2.5 text-xs font-bold tracking-widest uppercase"
|
aria-pressed={scope === option.value}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||||
|
scope === option.value ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{scope === "category" ? "This field" : "All images"}
|
{option.label}
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPending && assets.length === 0 && !error ? (
|
{isPending && assets.length === 0 && !error ? (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface MediaPickerProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
value: string | null | undefined;
|
value: string | null | undefined;
|
||||||
onChange: (url: string | null) => void;
|
onChange: (url: string | null) => void;
|
||||||
/** Tags new uploads and pre-filters the library to this field's own images — e.g. "branding", "loyalty", "staff", "service", "portfolio", "welcome". */
|
/** Tags new uploads with where they came from — e.g. "branding", "loyalty", "staff", "service", "portfolio", "welcome". The library itself is shared across every field; this doesn't hide other fields' images by default (see MediaManagerModal). */
|
||||||
category: string;
|
category: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
|
|||||||
const unread = !notification.readAt;
|
const unread = !notification.readAt;
|
||||||
const content = (
|
const content = (
|
||||||
<div
|
<div
|
||||||
className={`rounded-jumbo border p-4 transition-colors ${
|
className={`rounded-md border p-4 transition-colors ${
|
||||||
unread ? "border-accent/30 bg-accent/5" : "border-line bg-white"
|
unread ? "border-accent/30 bg-accent/5" : "border-line bg-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { prisma } from "@/lib/db/prisma";
|
|||||||
import { Permission } from "@/generated/prisma/enums";
|
import { Permission } from "@/generated/prisma/enums";
|
||||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||||
|
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||||
import { updateBusinessProfile, updateBusinessSettings } from "@/services/business/business.service";
|
import { updateBusinessProfile, updateBusinessSettings } from "@/services/business/business.service";
|
||||||
import { setReviewHidden } from "@/services/reviews/review-management.service";
|
import { setReviewHidden } from "@/services/reviews/review-management.service";
|
||||||
import { syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
|
import { syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
|
||||||
@@ -16,28 +17,42 @@ export interface ReviewsActionResult {
|
|||||||
|
|
||||||
async function requireReviewsManager() {
|
async function requireReviewsManager() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) return { businessId: null, error: "You must be signed in." } as const;
|
if (!session?.user) return { businessId: null, userId: null, error: "You must be signed in." } as const;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_REVIEWS);
|
await assertOwnerPermission(session.user.id, Permission.MANAGE_REVIEWS);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof OwnerPermissionDeniedError) return { businessId: null, error: error.message } as const;
|
if (error instanceof OwnerPermissionDeniedError) return { businessId: null, userId: null, error: error.message } as const;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await getActingBusinessContext();
|
const context = await getActingBusinessContext();
|
||||||
if (!context) return { businessId: null, error: "You must be signed in as staff." } as const;
|
if (!context) return { businessId: null, userId: null, error: "You must be signed in as staff." } as const;
|
||||||
|
|
||||||
return { businessId: context.businessId, error: null } as const;
|
return { businessId: context.businessId, userId: context.userId, error: null } as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reuses updateBusinessProfile/updateBusinessSettings (the same functions Business Details uses) rather than a parallel update path — googlePlaceId lives on Business, the two policy flags on BusinessSettings. */
|
/**
|
||||||
|
* Reuses updateBusinessProfile/updateBusinessSettings (the same functions
|
||||||
|
* Business Details uses) rather than a parallel update path —
|
||||||
|
* googlePlaceId lives on Business, the two policy flags on
|
||||||
|
* BusinessSettings. Deliberately no format regex on googlePlaceId beyond
|
||||||
|
* trimming — Google's own Place ID formats vary (place, postal code,
|
||||||
|
* plus-code IDs all look different) and this app has no need to validate
|
||||||
|
* more than "the owner typed something."
|
||||||
|
*
|
||||||
|
* Writes an audit log only when the Place ID actually changed — existing
|
||||||
|
* imported Review rows are deliberately left as-is when it changes (kept
|
||||||
|
* for history/audit, per docs/phase-10-summary.md's "Google Place ID"
|
||||||
|
* section); the next sync (scheduled or manual, via syncReviewsNowAction)
|
||||||
|
* naturally reconciles what's currently associated with the new Place ID.
|
||||||
|
*/
|
||||||
export async function updateReviewsSettingsAction(input: {
|
export async function updateReviewsSettingsAction(input: {
|
||||||
googlePlaceId: string | null;
|
googlePlaceId: string | null;
|
||||||
reviewsMinRating: number | null;
|
reviewsMinRating: number | null;
|
||||||
reviewsExcludeRatingOnly: boolean;
|
reviewsExcludeRatingOnly: boolean;
|
||||||
}): Promise<ReviewsActionResult> {
|
}): Promise<ReviewsActionResult> {
|
||||||
const { businessId, error } = await requireReviewsManager();
|
const { businessId, userId, error } = await requireReviewsManager();
|
||||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||||
|
|
||||||
const business = await prisma.business.findUnique({ where: { id: businessId } });
|
const business = await prisma.business.findUnique({ where: { id: businessId } });
|
||||||
@@ -45,6 +60,9 @@ export async function updateReviewsSettingsAction(input: {
|
|||||||
const location = await prisma.businessLocation.findFirst({ where: { businessId, isPrimary: true } });
|
const location = await prisma.businessLocation.findFirst({ where: { businessId, isPrimary: true } });
|
||||||
if (!location) return { success: false, error: "Business location not found." };
|
if (!location) return { success: false, error: "Business location not found." };
|
||||||
|
|
||||||
|
const nextPlaceId = input.googlePlaceId?.trim() || null;
|
||||||
|
const placeIdChanged = nextPlaceId !== business.googlePlaceId;
|
||||||
|
|
||||||
const profileResult = await updateBusinessProfile(prisma, {
|
const profileResult = await updateBusinessProfile(prisma, {
|
||||||
businessId,
|
businessId,
|
||||||
profile: {
|
profile: {
|
||||||
@@ -52,7 +70,7 @@ export async function updateReviewsSettingsAction(input: {
|
|||||||
timezone: business.timezone,
|
timezone: business.timezone,
|
||||||
currency: business.currency,
|
currency: business.currency,
|
||||||
locale: business.locale,
|
locale: business.locale,
|
||||||
googlePlaceId: input.googlePlaceId,
|
googlePlaceId: nextPlaceId,
|
||||||
},
|
},
|
||||||
location: {
|
location: {
|
||||||
addressLine1: location.addressLine1,
|
addressLine1: location.addressLine1,
|
||||||
@@ -70,6 +88,16 @@ export async function updateReviewsSettingsAction(input: {
|
|||||||
settings: { reviewsMinRating: input.reviewsMinRating, reviewsExcludeRatingOnly: input.reviewsExcludeRatingOnly },
|
settings: { reviewsMinRating: input.reviewsMinRating, reviewsExcludeRatingOnly: input.reviewsExcludeRatingOnly },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (placeIdChanged) {
|
||||||
|
await writeAuditLog(prisma, {
|
||||||
|
actorUserId: userId,
|
||||||
|
action: "GOOGLE_PLACE_ID_CHANGED",
|
||||||
|
entityType: "Business",
|
||||||
|
entityId: businessId,
|
||||||
|
metadata: { previousPlaceId: business.googlePlaceId, nextPlaceId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function AddStaffForm({ roles, services }: AddStaffFormProps) {
|
|||||||
|
|
||||||
if (inviteLink) {
|
if (inviteLink) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 rounded-jumbo border-line border bg-white p-6">
|
<div className="space-y-4 rounded-md border-line border bg-white p-6">
|
||||||
<p className="text-sm font-bold">{name} has been added.</p>
|
<p className="text-sm font-bold">{name} has been added.</p>
|
||||||
<p className="text-muted text-sm">
|
<p className="text-muted text-sm">
|
||||||
Email delivery isn't wired up yet — copy this invite link and send it to them yourself. It lets them
|
Email delivery isn't wired up yet — copy this invite link and send it to them yourself. It lets them
|
||||||
@@ -79,7 +79,7 @@ export function AddStaffForm({ roles, services }: AddStaffFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} />
|
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
<Input label="Email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
<Input label="Email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
<Input label="Phone (optional)" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
<Input label="Phone (optional)" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||||
@@ -95,7 +95,7 @@ export function AddStaffForm({ roles, services }: AddStaffFormProps) {
|
|||||||
<Switch label="Visible for online booking" checked={isBookableOnline} onCheckedChange={setIsBookableOnline} />
|
<Switch label="Visible for online booking" checked={isBookableOnline} onCheckedChange={setIsBookableOnline} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-jumbo border-line border bg-white p-6">
|
<div className="rounded-md border-line border bg-white p-6">
|
||||||
<p className="mb-3 text-xs font-bold tracking-widest uppercase">Services Offered</p>
|
<p className="mb-3 text-xs font-bold tracking-widest uppercase">Services Offered</p>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{services.map((s) => (
|
{services.map((s) => (
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
|
|||||||
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
|
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Booking Window</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Booking Window</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
<Input
|
<Input
|
||||||
label="Minimum notice (minutes)"
|
label="Minimum notice (minutes)"
|
||||||
hint="How soon before a slot a booking can be made."
|
hint="How soon before a slot a booking can be made."
|
||||||
@@ -60,7 +60,7 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Cancellation & Rescheduling</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Cancellation & Rescheduling</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
<Input
|
<Input
|
||||||
label="Free cancellation window (hours)"
|
label="Free cancellation window (hours)"
|
||||||
hint="Cancelling later than this may count as a late cancellation."
|
hint="Cancelling later than this may count as a late cancellation."
|
||||||
@@ -81,7 +81,7 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Scheduling</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Scheduling</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
<Select
|
<Select
|
||||||
label="Appointment slot interval"
|
label="Appointment slot interval"
|
||||||
value={String(form.slotIntervalMinutes)}
|
value={String(form.slotIntervalMinutes)}
|
||||||
@@ -128,7 +128,7 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Customer Options</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Customer Options</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
|
<div className="rounded-md border-line space-y-5 border bg-white p-6">
|
||||||
<Switch
|
<Switch
|
||||||
label="Customers can choose a barber"
|
label="Customers can choose a barber"
|
||||||
description='If off, "Anyone" is assigned automatically.'
|
description='If off, "Anyone" is assigned automatically.'
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
|||||||
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
|
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Profile</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Profile</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
<Input label="Business Name" required value={form.name} onChange={(e) => set("name", e.target.value)} />
|
<Input label="Business Name" required value={form.name} onChange={(e) => set("name", e.target.value)} />
|
||||||
<MediaPicker label="Logo" value={form.logoUrl} onChange={(url) => set("logoUrl", url)} category="branding" />
|
<MediaPicker label="Logo" value={form.logoUrl} onChange={(url) => set("logoUrl", url)} category="branding" />
|
||||||
<Textarea label="Description" value={form.description} onChange={(e) => set("description", e.target.value)} />
|
<Textarea label="Description" value={form.description} onChange={(e) => set("description", e.target.value)} />
|
||||||
@@ -124,7 +124,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Branding</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Branding</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
<MediaPicker
|
<MediaPicker
|
||||||
label="Cover Image"
|
label="Cover Image"
|
||||||
value={form.coverImageUrl}
|
value={form.coverImageUrl}
|
||||||
@@ -168,7 +168,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Contact & Address</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Contact & Address</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
<Input label="Street Address" required value={form.addressLine1} onChange={(e) => set("addressLine1", e.target.value)} />
|
<Input label="Street Address" required value={form.addressLine1} onChange={(e) => set("addressLine1", e.target.value)} />
|
||||||
<Input label="Address Line 2 (optional)" value={form.addressLine2} onChange={(e) => set("addressLine2", e.target.value)} />
|
<Input label="Address Line 2 (optional)" value={form.addressLine2} onChange={(e) => set("addressLine2", e.target.value)} />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -187,7 +187,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Social Links</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Social Links</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
<Input label="Instagram" placeholder="@yourshop" value={form.instagramUrl} onChange={(e) => set("instagramUrl", e.target.value)} />
|
<Input label="Instagram" placeholder="@yourshop" value={form.instagramUrl} onChange={(e) => set("instagramUrl", e.target.value)} />
|
||||||
<Input label="Facebook" placeholder="facebook.com/yourshop" value={form.facebookUrl} onChange={(e) => set("facebookUrl", e.target.value)} />
|
<Input label="Facebook" placeholder="facebook.com/yourshop" value={form.facebookUrl} onChange={(e) => set("facebookUrl", e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
@@ -195,7 +195,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Regional Settings</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Regional Settings</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
<Select label="Time Zone" value={form.timezone} onChange={(e) => set("timezone", e.target.value)}>
|
<Select label="Time Zone" value={form.timezone} onChange={(e) => set("timezone", e.target.value)}>
|
||||||
{TIMEZONES.map((tz) => (
|
{TIMEZONES.map((tz) => (
|
||||||
<option key={tz} value={tz}>
|
<option key={tz} value={tz}>
|
||||||
@@ -224,7 +224,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Policies</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Policies</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Cancellation Policy"
|
label="Cancellation Policy"
|
||||||
value={form.cancellationPolicy}
|
value={form.cancellationPolicy}
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export function CatalogueManager({ categories }: { categories: CatalogueCategory
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-jumbo border-line divide-line divide-y border bg-white">
|
<div className="rounded-md border-line divide-line divide-y border bg-white">
|
||||||
{category.services.map((service, serviceIndex) => (
|
{category.services.map((service, serviceIndex) => (
|
||||||
<div key={service.id} className="flex items-center gap-3 px-5 py-4">
|
<div key={service.id} className="flex items-center gap-3 px-5 py-4">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { LoyaltyCard } from "@/components/ui/loyalty-card";
|
|||||||
import { Select } from "@/components/ui/select";
|
import { Select } from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { MediaPicker } from "@/features/media/components/media-picker";
|
import { MediaPicker } from "@/features/media/components/media-picker";
|
||||||
|
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
|
||||||
|
import { cn } from "@/lib/utils/cn";
|
||||||
|
import { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
|
||||||
import { updateLoyaltyConfigAction } from "../actions/loyalty-config";
|
import { updateLoyaltyConfigAction } from "../actions/loyalty-config";
|
||||||
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
|
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
|
||||||
|
|
||||||
@@ -20,6 +23,18 @@ const REWARD_TYPES: { value: ProgrammeConfigInput["rewardType"]; label: string }
|
|||||||
{ value: "FREE_SERVICE", label: "Free Service" },
|
{ value: "FREE_SERVICE", label: "Free Service" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const FONT_OPTIONS: { value: LoyaltyCardFont; label: string }[] = [
|
||||||
|
{ value: LoyaltyCardFont.SANS, label: "Sans Serif" },
|
||||||
|
{ value: LoyaltyCardFont.SERIF, label: "Serif" },
|
||||||
|
{ value: LoyaltyCardFont.MONO, label: "Monospace" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEXT_SIZE_OPTIONS: { value: LoyaltyCardTextSize; label: string }[] = [
|
||||||
|
{ value: LoyaltyCardTextSize.SM, label: "Small" },
|
||||||
|
{ value: LoyaltyCardTextSize.MD, label: "Medium" },
|
||||||
|
{ value: LoyaltyCardTextSize.LG, label: "Large" },
|
||||||
|
];
|
||||||
|
|
||||||
export interface LoyaltyConfigFormProps {
|
export interface LoyaltyConfigFormProps {
|
||||||
businessName: string;
|
businessName: string;
|
||||||
initialConfig: ProgrammeConfigInput;
|
initialConfig: ProgrammeConfigInput;
|
||||||
@@ -70,7 +85,7 @@ export function LoyaltyConfigForm({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
|
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
|
||||||
<section className="rounded-jumbo border-line border bg-white p-6 lg:col-span-2">
|
<section className="rounded-md border-line border bg-white p-6 lg:col-span-2">
|
||||||
<Switch
|
<Switch
|
||||||
label="Loyalty programme enabled"
|
label="Loyalty programme enabled"
|
||||||
description="Turn off to stop earning/redeeming everywhere, without losing the configuration below."
|
description="Turn off to stop earning/redeeming everywhere, without losing the configuration below."
|
||||||
@@ -81,14 +96,56 @@ export function LoyaltyConfigForm({
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Reward Rules</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Reward Rules</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
|
<div className="rounded-md border-line space-y-5 border bg-white p-6">
|
||||||
<Input
|
<Input
|
||||||
label="Stamps required for reward"
|
label={`Stamps required for reward (1–${MAX_STAMPS_REQUIRED})`}
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
|
max={MAX_STAMPS_REQUIRED}
|
||||||
value={config.stampsRequired}
|
value={config.stampsRequired}
|
||||||
onChange={(e) => set("stampsRequired", Number(e.target.value))}
|
onChange={(e) => {
|
||||||
|
const next = Number(e.target.value);
|
||||||
|
set("stampsRequired", Number.isFinite(next) ? Math.min(Math.max(next, 1), MAX_STAMPS_REQUIRED) : 1);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase text-muted">Reward Timing</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
value: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||||
|
title: "Reward after all stamps",
|
||||||
|
description: `Collect all ${config.stampsRequired} stamps, then the reward is given as a separate perk — every position on the card is a normal stamp.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: RewardPositionMode.FINAL_SLOT_IS_REWARD,
|
||||||
|
title: "Final position is the reward",
|
||||||
|
description: `Collect ${resolveRewardThreshold({ stampsRequired: config.stampsRequired, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD })} stamps — the last position on the card is the reward itself, marked visually, not a stamp to fill.`,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
).map((option) => {
|
||||||
|
const selected = config.rewardPosition === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set("rewardPosition", option.value)}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-xl border p-4 text-left transition-colors",
|
||||||
|
selected ? "border-accent-loyalty bg-accent-loyalty/10" : "border-line bg-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-bold">{option.title}</p>
|
||||||
|
<p className="text-muted mt-1 text-xs">{option.description}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Select label="Reward Type" value={config.rewardType} onChange={(e) => set("rewardType", e.target.value as ProgrammeConfigInput["rewardType"])}>
|
<Select label="Reward Type" value={config.rewardType} onChange={(e) => set("rewardType", e.target.value as ProgrammeConfigInput["rewardType"])}>
|
||||||
{REWARD_TYPES.map((t) => (
|
{REWARD_TYPES.map((t) => (
|
||||||
<option key={t.value} value={t.value}>
|
<option key={t.value} value={t.value}>
|
||||||
@@ -131,7 +188,7 @@ export function LoyaltyConfigForm({
|
|||||||
|
|
||||||
<section className="space-y-3 lg:col-span-2">
|
<section className="space-y-3 lg:col-span-2">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Eligibility</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Eligibility</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-2 lg:gap-6 lg:space-y-0">
|
<div className="rounded-md border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-2 lg:gap-6 lg:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-bold">Eligible services (none checked = all services earn stamps)</p>
|
<p className="mb-2 text-xs font-bold">Eligible services (none checked = all services earn stamps)</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -163,7 +220,7 @@ export function LoyaltyConfigForm({
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules & Expiry</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules & Expiry</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
|
<div className="rounded-md border-line space-y-5 border bg-white p-6">
|
||||||
<Switch
|
<Switch
|
||||||
label="Apply reward automatically"
|
label="Apply reward automatically"
|
||||||
description="Redemption is always staff-initiated this phase — this setting is saved but has no automatic effect yet."
|
description="Redemption is always staff-initiated this phase — this setting is saved but has no automatic effect yet."
|
||||||
@@ -199,19 +256,29 @@ export function LoyaltyConfigForm({
|
|||||||
|
|
||||||
<section className="space-y-3 lg:col-span-2">
|
<section className="space-y-3 lg:col-span-2">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Card Appearance</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Card Appearance</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-[minmax(0,320px)_1fr] lg:gap-8 lg:space-y-0">
|
<div className="rounded-md border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-[minmax(0,320px)_1fr] lg:gap-8 lg:space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase">Live Preview</p>
|
<p className="text-muted mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase">Live Preview</p>
|
||||||
<LoyaltyCard
|
<LoyaltyCard
|
||||||
businessName={businessName}
|
businessName={businessName}
|
||||||
memberNumber="A1B2C3"
|
memberNumber="A1B2C3"
|
||||||
stampsRequired={Math.max(config.stampsRequired, 1)}
|
stampsRequired={Math.max(config.stampsRequired, 1)}
|
||||||
stampsEarned={Math.min(3, Math.max(config.stampsRequired - 1, 0))}
|
stampsEarned={Math.max(
|
||||||
|
resolveRewardThreshold({ stampsRequired: Math.max(config.stampsRequired, 1), rewardPosition: config.rewardPosition }) - 1,
|
||||||
|
0,
|
||||||
|
)}
|
||||||
rewardLabel={config.rewardLabel || "Reward"}
|
rewardLabel={config.rewardLabel || "Reward"}
|
||||||
termsLine={config.termsLine}
|
termsLine={config.termsLine}
|
||||||
cardBackgroundColor={config.cardBackgroundColor}
|
cardBackgroundColor={config.cardBackgroundColor}
|
||||||
cardBackgroundImageUrl={config.cardBackgroundImageUrl}
|
cardBackgroundImageUrl={config.cardBackgroundImageUrl}
|
||||||
stampIcon={config.stampIcon}
|
stampIcon={config.stampIcon}
|
||||||
|
rewardPosition={config.rewardPosition}
|
||||||
|
cardTopRightText={config.cardTopRightText}
|
||||||
|
cardFont={config.cardFont}
|
||||||
|
cardTopRightTextSize={config.cardTopRightTextSize}
|
||||||
|
cardTopRightTextColor={config.cardTopRightTextColor}
|
||||||
|
cardBorderColor={config.cardBorderColor}
|
||||||
|
cardMemberNumberColor={config.cardMemberNumberColor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-4 lg:mt-0">
|
<div className="mt-4 space-y-4 lg:mt-0">
|
||||||
@@ -226,6 +293,11 @@ export function LoyaltyConfigForm({
|
|||||||
onChange={(url) => set("cardBackgroundImageUrl", url ?? undefined)}
|
onChange={(url) => set("cardBackgroundImageUrl", url ?? undefined)}
|
||||||
category="loyalty"
|
category="loyalty"
|
||||||
/>
|
/>
|
||||||
|
<ColorPicker
|
||||||
|
label="Border colour"
|
||||||
|
value={config.cardBorderColor}
|
||||||
|
onChange={(hex) => set("cardBorderColor", hex)}
|
||||||
|
/>
|
||||||
<IconPicker label="Stamp icon" value={config.stampIcon} onChange={(iconName) => set("stampIcon", iconName)} />
|
<IconPicker label="Stamp icon" value={config.stampIcon} onChange={(iconName) => set("stampIcon", iconName)} />
|
||||||
<Input
|
<Input
|
||||||
label="Terms line"
|
label="Terms line"
|
||||||
@@ -233,6 +305,55 @@ export function LoyaltyConfigForm({
|
|||||||
onChange={(e) => set("termsLine", e.target.value)}
|
onChange={(e) => set("termsLine", e.target.value)}
|
||||||
placeholder="Valid for 12 months. One reward per visit."
|
placeholder="Valid for 12 months. One reward per visit."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="border-line border-t pt-4">
|
||||||
|
<p className="mb-3 text-xs font-bold">Top-right card text</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Text"
|
||||||
|
value={config.cardTopRightText ?? ""}
|
||||||
|
onChange={(e) => set("cardTopRightText", e.target.value)}
|
||||||
|
placeholder="e.g. VIP Member"
|
||||||
|
hint="Shown above the member number. Leave blank to show only the member number, as today."
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Select
|
||||||
|
label="Font"
|
||||||
|
value={config.cardFont}
|
||||||
|
onChange={(e) => set("cardFont", e.target.value as ProgrammeConfigInput["cardFont"])}
|
||||||
|
>
|
||||||
|
{FONT_OPTIONS.map((f) => (
|
||||||
|
<option key={f.value} value={f.value}>
|
||||||
|
{f.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
label="Text size"
|
||||||
|
value={config.cardTopRightTextSize}
|
||||||
|
onChange={(e) => set("cardTopRightTextSize", e.target.value as ProgrammeConfigInput["cardTopRightTextSize"])}
|
||||||
|
>
|
||||||
|
{TEXT_SIZE_OPTIONS.map((s) => (
|
||||||
|
<option key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted -mt-2 text-[10px]">Font applies to the whole card, not just this text.</p>
|
||||||
|
<ColorPicker
|
||||||
|
label="Text colour"
|
||||||
|
value={config.cardTopRightTextColor}
|
||||||
|
onChange={(hex) => set("cardTopRightTextColor", hex)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ColorPicker
|
||||||
|
label="Member number colour"
|
||||||
|
value={config.cardMemberNumberColor}
|
||||||
|
onChange={(hex) => set("cardMemberNumberColor", hex)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export function OpeningHoursForm({ initialRows }: { initialRows: InitialOpeningH
|
|||||||
Copy Monday to all days
|
Copy Monday to all days
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
|
<div className="divide-line rounded-md border-line divide-y border bg-white">
|
||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
|
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function ReviewsManager({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
<section className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
<h3 className="text-sm font-bold">Google Reviews Sync</h3>
|
<h3 className="text-sm font-bold">Google Reviews Sync</h3>
|
||||||
{googleRating !== null && (
|
{googleRating !== null && (
|
||||||
<p className="text-muted text-sm">
|
<p className="text-muted text-sm">
|
||||||
@@ -133,7 +133,7 @@ export function ReviewsManager({
|
|||||||
value={googlePlaceId}
|
value={googlePlaceId}
|
||||||
onChange={(e) => setGooglePlaceId(e.target.value)}
|
onChange={(e) => setGooglePlaceId(e.target.value)}
|
||||||
placeholder="ChIJ…"
|
placeholder="ChIJ…"
|
||||||
hint="Found via Google's Place ID Finder. Leave blank to disable review syncing for this business."
|
hint="Used to import this business's reviews from Google. Find it by searching your business on Google Maps, opening Share → Embed a map, and copying the id from the embed code — or search “Google Place ID Finder” for Google's own lookup tool. Leave blank to disable review syncing."
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function StaffDetailPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{hasPendingInvitation && (
|
{hasPendingInvitation && (
|
||||||
<section className="rounded-jumbo border-warning/30 bg-warning/10 border p-5">
|
<section className="rounded-md border-warning/30 bg-warning/10 border p-5">
|
||||||
<p className="text-warning-foreground text-sm font-bold">Invitation pending</p>
|
<p className="text-warning-foreground text-sm font-bold">Invitation pending</p>
|
||||||
<p className="text-muted mt-1 text-xs">
|
<p className="text-muted mt-1 text-xs">
|
||||||
This person hasn't set a password yet. Email delivery isn't wired up — send them the link
|
This person hasn't set a password yet. Email delivery isn't wired up — send them the link
|
||||||
@@ -148,7 +148,7 @@ export function StaffDetailPanel({
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Role & Permissions</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Role & Permissions</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
<Select label="Role" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
|
<Select label="Role" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
|
||||||
{roles.map((r) => (
|
{roles.map((r) => (
|
||||||
<option key={r.id} value={r.id}>
|
<option key={r.id} value={r.id}>
|
||||||
@@ -174,7 +174,7 @@ export function StaffDetailPanel({
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Services Provided</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Services Provided</h3>
|
||||||
<div className="rounded-jumbo border-line space-y-3 border bg-white p-6">
|
<div className="rounded-md border-line space-y-3 border bg-white p-6">
|
||||||
{allServices.map((s) => (
|
{allServices.map((s) => (
|
||||||
<Checkbox key={s.id} label={s.name} checked={serviceIds.includes(s.id)} onChange={() => toggleService(s.id)} />
|
<Checkbox key={s.id} label={s.name} checked={serviceIds.includes(s.id)} onChange={() => toggleService(s.id)} />
|
||||||
))}
|
))}
|
||||||
@@ -220,7 +220,7 @@ export function StaffDetailPanel({
|
|||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Availability</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Availability</h3>
|
||||||
<div className="rounded-jumbo border-line divide-line divide-y overflow-hidden border bg-white">
|
<div className="rounded-md border-line divide-line divide-y overflow-hidden border bg-white">
|
||||||
<Link href={`/owner/team/${staffId}/hours`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
|
<Link href={`/owner/team/${staffId}/hours`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
|
||||||
<span className="text-sm font-bold">Working Hours</span>
|
<span className="text-sm font-bold">Working Hours</span>
|
||||||
<span className="text-muted text-xs">Edit</span>
|
<span className="text-muted text-xs">Edit</span>
|
||||||
@@ -242,7 +242,7 @@ export function StaffDetailPanel({
|
|||||||
Reactivate Staff Member
|
Reactivate Staff Member
|
||||||
</Button>
|
</Button>
|
||||||
) : deactivateConflicts && deactivateConflicts.length > 0 ? (
|
) : deactivateConflicts && deactivateConflicts.length > 0 ? (
|
||||||
<div className="border-danger/30 bg-danger/5 space-y-3 rounded-jumbo border p-5">
|
<div className="border-danger/30 bg-danger/5 space-y-3 rounded-md border p-5">
|
||||||
<p role="alert" className="text-danger text-sm font-medium">
|
<p role="alert" className="text-danger text-sm font-medium">
|
||||||
This staff member still has {deactivateConflicts.length} upcoming appointment
|
This staff member still has {deactivateConflicts.length} upcoming appointment
|
||||||
{deactivateConflicts.length > 1 ? "s" : ""}. Deactivating won't cancel them — they'll need to
|
{deactivateConflicts.length > 1 ? "s" : ""}. Deactivating won't cancel them — they'll need to
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function ServiceWorkerManager() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
className="rounded-jumbo bg-ink fixed inset-x-6 top-4 z-50 mx-auto flex max-w-sm items-center justify-between gap-3 px-5 py-3 text-sm text-white shadow-xl"
|
className="rounded-md bg-ink fixed inset-x-6 top-4 z-50 mx-auto flex max-w-sm items-center justify-between gap-3 px-5 py-3 text-sm text-white shadow-xl"
|
||||||
>
|
>
|
||||||
<span>A new version is available.</span>
|
<span>A new version is available.</span>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function WelcomeInstallCard({ businessName }: { businessName: string }) {
|
|||||||
if (installed || dismissed || (!canPromptNatively && !ios)) return null;
|
if (installed || dismissed || (!canPromptNatively && !ios)) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-jumbo mb-6 border border-white/10 bg-white/5 p-5 text-white">
|
<div className="rounded-md mb-6 border border-white/10 bg-white/5 p-5 text-white">
|
||||||
<p className="text-sm font-bold">Add {businessName} to your Home Screen</p>
|
<p className="text-sm font-bold">Add {businessName} to your Home Screen</p>
|
||||||
<p className="mt-1 text-sm text-white/70">Book appointments, manage your visits and receive reminders more easily.</p>
|
<p className="mt-1 text-sm text-white/70">Book appointments, manage your visits and receive reminders more easily.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function BookFollowUpButton({ appointmentId }: { appointmentId: string })
|
|||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
|
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
|
||||||
isLoading={isPending}
|
isLoading={isPending}
|
||||||
onClick={() => startTransition(() => startStaffFollowUpAction(appointmentId))}
|
onClick={() => startTransition(() => startStaffFollowUpAction(appointmentId))}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -69,11 +69,11 @@ export function BreaksList({
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{breaks.length === 0 ? (
|
{breaks.length === 0 ? (
|
||||||
<p className="text-muted rounded-jumbo border border-line bg-white p-5 text-center text-sm">
|
<p className="text-muted rounded-md border border-line bg-white p-5 text-center text-sm">
|
||||||
No recurring breaks yet.
|
No recurring breaks yet.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
|
<ul className="divide-y divide-line rounded-md border border-line bg-white">
|
||||||
{breaks.map((b) => (
|
{breaks.map((b) => (
|
||||||
<li key={b.id}>
|
<li key={b.id}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function NotificationPreferencesForm({ initial, action }: NotificationPre
|
|||||||
optional. Choose how you'd like to hear about upcoming appointment reminders.
|
optional. Choose how you'd like to hear about upcoming appointment reminders.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="rounded-jumbo border-line divide-line divide-y overflow-hidden border bg-white">
|
<div className="rounded-md border-line divide-line divide-y overflow-hidden border bg-white">
|
||||||
{CHANNEL_ORDER.map((channel) => (
|
{CHANNEL_ORDER.map((channel) => (
|
||||||
<div key={channel} className="flex items-center justify-between px-4 py-3">
|
<div key={channel} className="flex items-center justify-between px-4 py-3">
|
||||||
<span className="text-sm font-medium">Reminders via {CHANNEL_LABEL[channel]}</span>
|
<span className="text-sm font-medium">Reminders via {CHANNEL_LABEL[channel]}</span>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function ProfileForm({ initial }: { initial: ProfileFormInitial }) {
|
|||||||
onChange={(e) => setBio(e.target.value)}
|
onChange={(e) => setBio(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="rounded-jumbo border border-line bg-white p-5">
|
<div className="rounded-md border border-line bg-white p-5">
|
||||||
<Switch
|
<Switch
|
||||||
label="Visible for online booking"
|
label="Visible for online booking"
|
||||||
description="Customers can find and book you when this is on."
|
description="Customers can find and book you when this is on."
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ export function TimeOffList({
|
|||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Upcoming</p>
|
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Upcoming</p>
|
||||||
{upcoming.length === 0 ? (
|
{upcoming.length === 0 ? (
|
||||||
<p className="text-muted rounded-jumbo border border-line bg-white p-5 text-center text-sm">
|
<p className="text-muted rounded-md border border-line bg-white p-5 text-center text-sm">
|
||||||
No upcoming time off.
|
No upcoming time off.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
|
<ul className="divide-y divide-line rounded-md border border-line bg-white">
|
||||||
{upcoming.map((t) => (
|
{upcoming.map((t) => (
|
||||||
<li key={t.id}>
|
<li key={t.id}>
|
||||||
<button
|
<button
|
||||||
@@ -74,7 +74,7 @@ export function TimeOffList({
|
|||||||
{past.length > 0 && (
|
{past.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Past</p>
|
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Past</p>
|
||||||
<ul className="divide-y divide-line rounded-jumbo border border-line bg-stone/50">
|
<ul className="divide-y divide-line rounded-md border border-line bg-stone/50">
|
||||||
{past.map((t) => (
|
{past.map((t) => (
|
||||||
<li key={t.id} className="px-5 py-4">
|
<li key={t.id} className="px-5 py-4">
|
||||||
<p className="text-sm font-bold text-muted">{formatRange(t.startDate, t.endDate)}</p>
|
<p className="text-sm font-bold text-muted">{formatRange(t.startDate, t.endDate)}</p>
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export function WorkingHoursForm({
|
|||||||
Copy Monday to all days
|
Copy Monday to all days
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
|
<div className="divide-line rounded-md border-line divide-y border bg-white">
|
||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
|
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
faCheck,
|
faCheck,
|
||||||
faCircleCheck,
|
faCircleCheck,
|
||||||
faClock,
|
faClock,
|
||||||
faCoffee,
|
|
||||||
faCompass,
|
faCompass,
|
||||||
faCrown,
|
faCrown,
|
||||||
faDiamond,
|
faDiamond,
|
||||||
@@ -76,7 +75,6 @@ export const ICON_CATALOG: IconDefinition[] = [
|
|||||||
faBolt,
|
faBolt,
|
||||||
faSun,
|
faSun,
|
||||||
faLeaf,
|
faLeaf,
|
||||||
faCoffee,
|
|
||||||
faMugSaucer,
|
faMugSaucer,
|
||||||
faGlassCheers,
|
faGlassCheers,
|
||||||
faHandSparkles,
|
faHandSparkles,
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real filesystem I/O against a throwaway temp directory (via
|
||||||
|
* MEDIA_STORAGE_ROOT), not mocks — this module's whole job is getting
|
||||||
|
* real file paths right, so a mocked `fs` would test nothing useful. No
|
||||||
|
* DATABASE_URL/Prisma dependency here (media-storage.ts is DB-free), so
|
||||||
|
* this stays safe to run in the plain unit suite. The module is
|
||||||
|
* dynamically imported after MEDIA_STORAGE_ROOT is set, since it reads
|
||||||
|
* that env var once at module-load time.
|
||||||
|
*/
|
||||||
|
describe("media-storage", () => {
|
||||||
|
let tempRoot: string;
|
||||||
|
let storage: typeof import("../media-storage");
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempRoot = await mkdtemp(path.join(tmpdir(), "nextchair-media-test-"));
|
||||||
|
process.env.MEDIA_STORAGE_ROOT = tempRoot;
|
||||||
|
storage = await import("../media-storage");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.MEDIA_STORAGE_ROOT;
|
||||||
|
await rm(tempRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const PNG_BYTES = Buffer.from(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||||
|
"base64",
|
||||||
|
);
|
||||||
|
|
||||||
|
it("stores a file and returns a stable /api/media/ URL", async () => {
|
||||||
|
const result = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "branding" });
|
||||||
|
expect(result.url).toMatch(/^\/api\/media\/branding\/[a-f0-9-]+\.png$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readStoredMediaFile resolves the stored file back with the right bytes and content type", async () => {
|
||||||
|
const stored = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "gallery" });
|
||||||
|
const segments = stored.url.replace(storage.MEDIA_URL_PREFIX, "").split("/");
|
||||||
|
|
||||||
|
const file = await storage.readStoredMediaFile(segments);
|
||||||
|
expect(file).not.toBeNull();
|
||||||
|
expect(file?.mimeType).toBe("image/png");
|
||||||
|
expect(file?.buffer.equals(PNG_BYTES)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readStoredMediaFile returns null for a file that was never written", async () => {
|
||||||
|
const file = await storage.readStoredMediaFile(["branding", "does-not-exist.png"]);
|
||||||
|
expect(file).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteMediaFile removes the file — a subsequent read returns null", async () => {
|
||||||
|
const stored = await storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/png", category: "staff" });
|
||||||
|
const segments = stored.url.replace(storage.MEDIA_URL_PREFIX, "").split("/");
|
||||||
|
|
||||||
|
expect(await storage.readStoredMediaFile(segments)).not.toBeNull();
|
||||||
|
await storage.deleteMediaFile(stored.url);
|
||||||
|
expect(await storage.readStoredMediaFile(segments)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deleteMediaFile on an external (non-/api/media/) URL is a safe no-op", async () => {
|
||||||
|
await expect(storage.deleteMediaFile("https://example.com/logo.png")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsupported mime types before ever touching the filesystem", async () => {
|
||||||
|
await expect(storage.storeMediaFile({ buffer: PNG_BYTES, mimeType: "image/svg+xml", category: "branding" })).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks path traversal via '..' segments", () => {
|
||||||
|
expect(storage.resolveMediaFilePath(["..", "..", "etc", "passwd"])).toBeNull();
|
||||||
|
expect(storage.resolveMediaFilePath(["branding", ".."])).toBeNull();
|
||||||
|
expect(storage.resolveMediaFilePath(["branding", "..", "..", "package.json"])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks segments that smuggle a path separator", () => {
|
||||||
|
expect(storage.resolveMediaFilePath(["branding/../../../etc/passwd"])).toBeNull();
|
||||||
|
expect(storage.resolveMediaFilePath(["a\\b"])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readStoredMediaFile returns null for a path-traversal attempt even if a file exists there", async () => {
|
||||||
|
// Prove the guard rejects it via the real read path, not just the resolver in isolation.
|
||||||
|
const file = await storage.readStoredMediaFile(["..", "..", "..", "etc", "passwd"]);
|
||||||
|
expect(file).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mimeTypeForFilename covers every allowed extension and rejects unknown ones", () => {
|
||||||
|
expect(storage.mimeTypeForFilename("x.png")).toBe("image/png");
|
||||||
|
expect(storage.mimeTypeForFilename("x.jpg")).toBe("image/jpeg");
|
||||||
|
expect(storage.mimeTypeForFilename("x.jpeg")).toBe("image/jpeg");
|
||||||
|
expect(storage.mimeTypeForFilename("x.webp")).toBe("image/webp");
|
||||||
|
expect(storage.mimeTypeForFilename("x.gif")).toBe("image/gif");
|
||||||
|
expect(storage.mimeTypeForFilename("x.svg")).toBeNull();
|
||||||
|
expect(storage.mimeTypeForFilename("x")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +1,30 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
import { access, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Local-disk storage for the Media Manager (see src/services/media) —
|
* Local-disk storage for the Media Manager (see src/services/media).
|
||||||
* files live under public/media/<category>/..., served for free by
|
*
|
||||||
* Next's own static file handling for public/ (no custom GET route
|
* Files live under a storage root OUTSIDE `public/` (`storage/media` by
|
||||||
* needed). A deliberate v1 choice for this app's single-container Docker
|
* default — `/app/storage/media` in the Docker image, matching the
|
||||||
* deployment model, not a cloud object-storage integration — see
|
* `next-chair-media-data` volume in docker-compose.local.yaml). This was
|
||||||
* docker-compose.local.yaml's media volume for why this survives a
|
* previously `public/media/`, served by Next's own static file handling
|
||||||
* container restart. Swapping to S3-compatible storage later only means
|
* — that turned out not to work: verified directly against a real
|
||||||
* changing this one file; every caller only ever sees a public URL.
|
* production build/container that Next.js 16's `next start` does NOT
|
||||||
|
* dynamically serve files added to `public/` after the build (a
|
||||||
|
* build-time-present file served 200; a file written into `public/media`
|
||||||
|
* at runtime served 404, even though it existed on disk with correct
|
||||||
|
* permissions). See src/app/api/media/[...path]/route.ts, the dedicated
|
||||||
|
* route that now serves these files itself instead of relying on Next's
|
||||||
|
* public-asset resolution.
|
||||||
*
|
*
|
||||||
* SVG is deliberately not in the allow-list — an uploaded SVG can embed
|
* SVG is deliberately not in the allow-list — an uploaded SVG can embed
|
||||||
* <script>, making it a stored-XSS vector if ever served inline.
|
* <script>, making it a stored-XSS vector if ever served inline.
|
||||||
|
*
|
||||||
|
* Kept provider-neutral by design: every caller only ever sees a
|
||||||
|
* MediaAsset.url string and calls storeMediaFile/readStoredMediaFile/
|
||||||
|
* deleteMediaFile — swapping this file for an S3-compatible backend
|
||||||
|
* later doesn't require touching any caller.
|
||||||
*/
|
*/
|
||||||
const ALLOWED_MIME_EXTENSIONS: Record<string, string> = {
|
const ALLOWED_MIME_EXTENSIONS: Record<string, string> = {
|
||||||
"image/png": "png",
|
"image/png": "png",
|
||||||
@@ -22,17 +33,27 @@ const ALLOWED_MIME_EXTENSIONS: Record<string, string> = {
|
|||||||
"image/gif": "gif",
|
"image/gif": "gif",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EXTENSION_MIME_TYPES: Record<string, string> = {
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
webp: "image/webp",
|
||||||
|
gif: "image/gif",
|
||||||
|
};
|
||||||
|
|
||||||
export const MEDIA_MAX_UPLOAD_BYTES = Number(process.env.MEDIA_MAX_UPLOAD_BYTES ?? 5_000_000);
|
export const MEDIA_MAX_UPLOAD_BYTES = Number(process.env.MEDIA_MAX_UPLOAD_BYTES ?? 5_000_000);
|
||||||
|
|
||||||
const MEDIA_ROOT = path.join(process.cwd(), "public", "media");
|
/** Overridable so a non-Docker deployment can point this somewhere else; defaults to a directory next to the app, outside public/. */
|
||||||
const MEDIA_URL_PREFIX = "/media/";
|
const MEDIA_ROOT = path.resolve(process.env.MEDIA_STORAGE_ROOT || path.join(process.cwd(), "storage", "media"));
|
||||||
|
|
||||||
|
export const MEDIA_URL_PREFIX = "/api/media/";
|
||||||
|
|
||||||
export function isAllowedMediaMimeType(mimeType: string): boolean {
|
export function isAllowedMediaMimeType(mimeType: string): boolean {
|
||||||
return mimeType in ALLOWED_MIME_EXTENSIONS;
|
return mimeType in ALLOWED_MIME_EXTENSIONS;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredMediaFile {
|
export interface StoredMediaFile {
|
||||||
/** Public path — what gets saved on MediaAsset.url and rendered directly. */
|
/** Public path — what gets saved on MediaAsset.url and rendered directly. Resolved by src/app/api/media/[...path]/route.ts, not Next's static handling. */
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +61,54 @@ function sanitizeCategory(category: string): string {
|
|||||||
return category.replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "other";
|
return category.replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a `/api/media/<...segments>` route param array to an absolute
|
||||||
|
* path, refusing anything that would escape MEDIA_ROOT — the only
|
||||||
|
* defense that actually matters against path traversal (`..` segments,
|
||||||
|
* absolute-path segments, encoded separators once decoded by Next's own
|
||||||
|
* routing). Returns null for anything unsafe rather than throwing, so
|
||||||
|
* callers can turn that straight into a 404 without distinguishing
|
||||||
|
* "malicious" from "malformed."
|
||||||
|
*/
|
||||||
|
export function resolveMediaFilePath(segments: string[]): string | null {
|
||||||
|
if (segments.length === 0) return null;
|
||||||
|
if (segments.some((segment) => !segment || segment === "." || segment === ".." || segment.includes("/") || segment.includes("\\"))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const absolutePath = path.resolve(MEDIA_ROOT, ...segments);
|
||||||
|
if (absolutePath !== MEDIA_ROOT && !absolutePath.startsWith(MEDIA_ROOT + path.sep)) return null;
|
||||||
|
return absolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mimeTypeForFilename(filename: string): string | null {
|
||||||
|
const extension = filename.split(".").pop()?.toLowerCase();
|
||||||
|
if (!extension) return null;
|
||||||
|
return EXTENSION_MIME_TYPES[extension] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredMediaFileContent {
|
||||||
|
buffer: Buffer;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Used only by GET /api/media/[...path] — returns null for "doesn't exist" or "not a regular file," never throws, so the route can turn that into a clean 404. */
|
||||||
|
export async function readStoredMediaFile(segments: string[]): Promise<StoredMediaFileContent | null> {
|
||||||
|
const absolutePath = resolveMediaFilePath(segments);
|
||||||
|
if (!absolutePath) return null;
|
||||||
|
|
||||||
|
const mimeType = mimeTypeForFilename(absolutePath);
|
||||||
|
if (!mimeType) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileStat = await stat(absolutePath);
|
||||||
|
if (!fileStat.isFile()) return null;
|
||||||
|
const buffer = await readFile(absolutePath);
|
||||||
|
return { buffer, mimeType };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function storeMediaFile(params: {
|
export async function storeMediaFile(params: {
|
||||||
buffer: Buffer;
|
buffer: Buffer;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
@@ -51,8 +120,20 @@ export async function storeMediaFile(params: {
|
|||||||
const safeCategory = sanitizeCategory(params.category);
|
const safeCategory = sanitizeCategory(params.category);
|
||||||
const filename = `${randomUUID()}.${extension}`;
|
const filename = `${randomUUID()}.${extension}`;
|
||||||
const dir = path.join(MEDIA_ROOT, safeCategory);
|
const dir = path.join(MEDIA_ROOT, safeCategory);
|
||||||
|
const absolutePath = path.join(dir, filename);
|
||||||
|
|
||||||
await mkdir(dir, { recursive: true });
|
await mkdir(dir, { recursive: true });
|
||||||
await writeFile(path.join(dir, filename), params.buffer);
|
await writeFile(absolutePath, params.buffer);
|
||||||
|
|
||||||
|
// Never report success on a write we can't actually read back — see
|
||||||
|
// this file's header. A silent permission/filesystem issue on write
|
||||||
|
// (e.g. a volume mounted read-only) should surface as a failed upload,
|
||||||
|
// not a broken URL discovered later.
|
||||||
|
try {
|
||||||
|
await access(absolutePath);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Upload failed: the file could not be verified after writing.");
|
||||||
|
}
|
||||||
|
|
||||||
return { url: `${MEDIA_URL_PREFIX}${safeCategory}/${filename}` };
|
return { url: `${MEDIA_URL_PREFIX}${safeCategory}/${filename}` };
|
||||||
}
|
}
|
||||||
@@ -61,8 +142,7 @@ export async function storeMediaFile(params: {
|
|||||||
export async function deleteMediaFile(url: string): Promise<void> {
|
export async function deleteMediaFile(url: string): Promise<void> {
|
||||||
if (!url.startsWith(MEDIA_URL_PREFIX)) return;
|
if (!url.startsWith(MEDIA_URL_PREFIX)) return;
|
||||||
const relative = url.slice(MEDIA_URL_PREFIX.length);
|
const relative = url.slice(MEDIA_URL_PREFIX.length);
|
||||||
const absolutePath = path.join(MEDIA_ROOT, relative);
|
const absolutePath = resolveMediaFilePath(relative.split("/"));
|
||||||
// Path-traversal guard — a corrupted/crafted url should never resolve outside MEDIA_ROOT.
|
if (!absolutePath) return;
|
||||||
if (!absolutePath.startsWith(MEDIA_ROOT + path.sep)) return;
|
|
||||||
await unlink(absolutePath).catch(() => {});
|
await unlink(absolutePath).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { validateProgrammeConfig } from "../loyalty.service";
|
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
|
||||||
|
import { resolveRewardThreshold, validateProgrammeConfig } from "../loyalty.service";
|
||||||
import type { ProgrammeConfigInput } from "../loyalty.service";
|
import type { ProgrammeConfigInput } from "../loyalty.service";
|
||||||
|
|
||||||
const base: ProgrammeConfigInput = {
|
const base: ProgrammeConfigInput = {
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
stampsRequired: 10,
|
stampsRequired: 10,
|
||||||
|
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||||
rewardType: "PERCENTAGE",
|
rewardType: "PERCENTAGE",
|
||||||
rewardValue: "50",
|
rewardValue: "50",
|
||||||
autoApplyReward: true,
|
autoApplyReward: true,
|
||||||
requiresStaffApproval: false,
|
requiresStaffApproval: false,
|
||||||
earnOnCancelled: false,
|
earnOnCancelled: false,
|
||||||
earnOnNoShow: false,
|
earnOnNoShow: false,
|
||||||
|
cardFont: LoyaltyCardFont.SANS,
|
||||||
|
cardTopRightTextSize: LoyaltyCardTextSize.MD,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("validateProgrammeConfig", () => {
|
describe("validateProgrammeConfig", () => {
|
||||||
@@ -63,3 +67,21 @@ describe("validateProgrammeConfig", () => {
|
|||||||
expect(result.issues[0]?.code).toBe("FREE_SERVICE_REQUIRED");
|
expect(result.issues[0]?.code).toBe("FREE_SERVICE_REQUIRED");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("resolveRewardThreshold", () => {
|
||||||
|
it("AFTER_ALL_STAMPS requires every position filled", () => {
|
||||||
|
expect(resolveRewardThreshold({ stampsRequired: 10, rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS })).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FINAL_SLOT_IS_REWARD is earned one stamp early — the last position is the reward, not a stamp to collect", () => {
|
||||||
|
expect(
|
||||||
|
resolveRewardThreshold({ stampsRequired: 10, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD }),
|
||||||
|
).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps FINAL_SLOT_IS_REWARD at a minimum threshold of 1, even for a single-stamp card", () => {
|
||||||
|
expect(
|
||||||
|
resolveRewardThreshold({ stampsRequired: 1, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD }),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
|
||||||
import {
|
import {
|
||||||
expireOverdueRewards,
|
expireOverdueRewards,
|
||||||
redeemReward,
|
redeemReward,
|
||||||
@@ -32,12 +33,15 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
|||||||
input: {
|
input: {
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
stampsRequired: 8,
|
stampsRequired: 8,
|
||||||
|
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||||
rewardType: "PERCENTAGE",
|
rewardType: "PERCENTAGE",
|
||||||
rewardValue: "25",
|
rewardValue: "25",
|
||||||
autoApplyReward: true,
|
autoApplyReward: true,
|
||||||
requiresStaffApproval: false,
|
requiresStaffApproval: false,
|
||||||
earnOnCancelled: false,
|
earnOnCancelled: false,
|
||||||
earnOnNoShow: false,
|
earnOnNoShow: false,
|
||||||
|
cardFont: LoyaltyCardFont.SANS,
|
||||||
|
cardTopRightTextSize: LoyaltyCardTextSize.MD,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(first.success).toBe(true);
|
expect(first.success).toBe(true);
|
||||||
@@ -50,19 +54,22 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
|||||||
businessId: business.businessId,
|
businessId: business.businessId,
|
||||||
input: {
|
input: {
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
stampsRequired: 12,
|
stampsRequired: 10, // max allowed — see validateProgrammeConfig's 1-10 range (Phase 10)
|
||||||
|
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||||
rewardType: "PERCENTAGE",
|
rewardType: "PERCENTAGE",
|
||||||
rewardValue: "40",
|
rewardValue: "40",
|
||||||
autoApplyReward: true,
|
autoApplyReward: true,
|
||||||
requiresStaffApproval: false,
|
requiresStaffApproval: false,
|
||||||
earnOnCancelled: false,
|
earnOnCancelled: false,
|
||||||
earnOnNoShow: false,
|
earnOnNoShow: false,
|
||||||
|
cardFont: LoyaltyCardFont.SANS,
|
||||||
|
cardTopRightTextSize: LoyaltyCardTextSize.MD,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(second.success).toBe(true);
|
expect(second.success).toBe(true);
|
||||||
|
|
||||||
const updated = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
|
const updated = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
|
||||||
expect(updated.stampsRequired).toBe(12);
|
expect(updated.stampsRequired).toBe(10);
|
||||||
expect(Number(updated.rewardValue)).toBe(40);
|
expect(Number(updated.rewardValue)).toBe(40);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,12 +84,15 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
|
|||||||
input: {
|
input: {
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
stampsRequired: 10,
|
stampsRequired: 10,
|
||||||
|
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
|
||||||
rewardType: "FREE_SERVICE",
|
rewardType: "FREE_SERVICE",
|
||||||
freeServiceId: inactiveService.id,
|
freeServiceId: inactiveService.id,
|
||||||
autoApplyReward: true,
|
autoApplyReward: true,
|
||||||
requiresStaffApproval: false,
|
requiresStaffApproval: false,
|
||||||
earnOnCancelled: false,
|
earnOnCancelled: false,
|
||||||
earnOnNoShow: false,
|
earnOnNoShow: false,
|
||||||
|
cardFont: LoyaltyCardFont.SANS,
|
||||||
|
cardTopRightTextSize: LoyaltyCardTextSize.MD,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { RewardType } from "@/generated/prisma/enums";
|
import { RewardPositionMode, RewardType } from "@/generated/prisma/enums";
|
||||||
import {
|
import {
|
||||||
addStamp,
|
addStamp,
|
||||||
awardStampForCompletedAppointment,
|
awardStampForCompletedAppointment,
|
||||||
correctStamp,
|
correctStamp,
|
||||||
getLoyaltyHistory,
|
getLoyaltyHistory,
|
||||||
getLoyaltySummary,
|
getLoyaltySummary,
|
||||||
|
redeemReward,
|
||||||
} from "../loyalty.service";
|
} from "../loyalty.service";
|
||||||
import {
|
import {
|
||||||
cleanupTestBusiness,
|
cleanupTestBusiness,
|
||||||
@@ -24,12 +25,13 @@ import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
|||||||
describe("LoyaltyService (integration)", () => {
|
describe("LoyaltyService (integration)", () => {
|
||||||
let business: TestBusiness;
|
let business: TestBusiness;
|
||||||
|
|
||||||
async function enableLoyalty(overrides: { stampsRequired?: number } = {}) {
|
async function enableLoyalty(overrides: { stampsRequired?: number; rewardPosition?: RewardPositionMode } = {}) {
|
||||||
return prisma.loyaltyProgramme.create({
|
return prisma.loyaltyProgramme.create({
|
||||||
data: {
|
data: {
|
||||||
businessId: business.businessId,
|
businessId: business.businessId,
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
stampsRequired: overrides.stampsRequired ?? 5,
|
stampsRequired: overrides.stampsRequired ?? 5,
|
||||||
|
rewardPosition: overrides.rewardPosition ?? RewardPositionMode.AFTER_ALL_STAMPS,
|
||||||
rewardType: RewardType.PERCENTAGE,
|
rewardType: RewardType.PERCENTAGE,
|
||||||
rewardValue: 50,
|
rewardValue: 50,
|
||||||
rewardLabel: "50% Off",
|
rewardLabel: "50% Off",
|
||||||
@@ -144,6 +146,67 @@ describe("LoyaltyService (integration)", () => {
|
|||||||
expect(account.currentStamps).toBe(5);
|
expect(account.currentStamps).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("reward position modes", () => {
|
||||||
|
it("FINAL_SLOT_IS_REWARD earns the reward one stamp early via a manual add — the same threshold AFTER_ALL_STAMPS would need one more stamp to cross", async () => {
|
||||||
|
const programme = await enableLoyalty({ stampsRequired: 3, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD });
|
||||||
|
const { staffId } = await createTestStaff(business);
|
||||||
|
const { customerId } = await createTestCustomer(business);
|
||||||
|
|
||||||
|
const shortOfAfterAllStamps = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 2, staffId });
|
||||||
|
expect(shortOfAfterAllStamps.rewardCreated).toBe(true); // 2 stamps == threshold (stampsRequired - 1)
|
||||||
|
|
||||||
|
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
|
||||||
|
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
|
||||||
|
});
|
||||||
|
expect(account.currentStamps).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FINAL_SLOT_IS_REWARD uses the same threshold for the automatic completed-appointment path as for a manual add", async () => {
|
||||||
|
await enableLoyalty({ stampsRequired: 3, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD });
|
||||||
|
const { staffId } = await createTestStaff(business);
|
||||||
|
const { customerId } = await createTestCustomer(business);
|
||||||
|
|
||||||
|
// One manual stamp, then one automatic one from a completed appointment — together they
|
||||||
|
// cross the threshold (2), and both paths must agree that's enough to earn the reward.
|
||||||
|
await addStamp(prisma, { customerId, businessId: business.businessId, amount: 1, staffId });
|
||||||
|
const appointment = await completedAppointment(staffId, customerId);
|
||||||
|
const result = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
|
||||||
|
|
||||||
|
expect(result.rewardCreated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redemption deducts exactly the earning threshold, not the raw stampsRequired total, in FINAL_SLOT_IS_REWARD mode", async () => {
|
||||||
|
const programme = await enableLoyalty({ stampsRequired: 4, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD });
|
||||||
|
const { staffId } = await createTestStaff(business);
|
||||||
|
const { customerId } = await createTestCustomer(business);
|
||||||
|
const appointment = await completedAppointment(staffId, customerId);
|
||||||
|
|
||||||
|
// 3 stamps == threshold (stampsRequired - 1) — earns the reward without ever reaching 4.
|
||||||
|
const added = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 3, staffId });
|
||||||
|
expect(added.rewardCreated).toBe(true);
|
||||||
|
|
||||||
|
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
|
||||||
|
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
|
||||||
|
});
|
||||||
|
const reward = await prisma.loyaltyReward.findFirstOrThrow({
|
||||||
|
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const redemption = await redeemReward(prisma, {
|
||||||
|
rewardId: reward.id,
|
||||||
|
appointmentId: appointment.id,
|
||||||
|
staffId,
|
||||||
|
businessId: business.businessId,
|
||||||
|
});
|
||||||
|
expect(redemption.success).toBe(true);
|
||||||
|
|
||||||
|
const afterRedemption = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
|
||||||
|
// Deducting the raw stampsRequired (4) from a balance of 3 would have gone negative —
|
||||||
|
// clampedDeduction alone would mask the bug. Deducting the threshold (3) leaves exactly 0.
|
||||||
|
expect(afterRedemption.currentStamps).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a manual stamp when loyalty is disabled for the business", async () => {
|
it("rejects a manual stamp when loyalty is disabled for the business", async () => {
|
||||||
// No enableLoyalty() call — business has no loyalty programme at all.
|
// No enableLoyalty() call — business has no loyalty programme at all.
|
||||||
const { staffId } = await createTestStaff(business);
|
const { staffId } = await createTestStaff(business);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { LoyaltyProgramme, PrismaClient } from "@/generated/prisma/client";
|
import type { LoyaltyProgramme, PrismaClient } from "@/generated/prisma/client";
|
||||||
|
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
|
||||||
import { fail, ok } from "@/lib/validation-result";
|
import { fail, ok } from "@/lib/validation-result";
|
||||||
import type { ValidationResult } from "@/lib/validation-result";
|
import type { ValidationResult } from "@/lib/validation-result";
|
||||||
import {
|
import {
|
||||||
@@ -7,6 +8,9 @@ import {
|
|||||||
notifyLoyaltyRewardRedeemed,
|
notifyLoyaltyRewardRedeemed,
|
||||||
notifyLoyaltyStampAdded,
|
notifyLoyaltyStampAdded,
|
||||||
} from "./loyalty-notifications";
|
} from "./loyalty-notifications";
|
||||||
|
import { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "./reward-threshold";
|
||||||
|
|
||||||
|
export { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "./reward-threshold";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ledger-based loyalty, per the brief: the cached `LoyaltyAccount.currentStamps`
|
* Ledger-based loyalty, per the brief: the cached `LoyaltyAccount.currentStamps`
|
||||||
@@ -45,11 +49,19 @@ export interface LoyaltySummary {
|
|||||||
accountId: string | null;
|
accountId: string | null;
|
||||||
currentStamps: number;
|
currentStamps: number;
|
||||||
stampsRequired: number;
|
stampsRequired: number;
|
||||||
|
rewardPosition: RewardPositionMode;
|
||||||
availableReward: { id: string; label: string; createdAt: Date; expiresAt: Date | null } | null;
|
availableReward: { id: string; label: string; createdAt: Date; expiresAt: Date | null } | null;
|
||||||
rewardLabel: string | null;
|
rewardLabel: string | null;
|
||||||
stampIcon: string | null;
|
stampIcon: string | null;
|
||||||
cardBackgroundColor: string | null;
|
cardBackgroundColor: string | null;
|
||||||
cardBackgroundImageUrl: string | null;
|
cardBackgroundImageUrl: string | null;
|
||||||
|
termsLine: string | null;
|
||||||
|
cardTopRightText: string | null;
|
||||||
|
cardFont: LoyaltyCardFont;
|
||||||
|
cardTopRightTextSize: LoyaltyCardTextSize;
|
||||||
|
cardTopRightTextColor: string | null;
|
||||||
|
cardBorderColor: string | null;
|
||||||
|
cardMemberNumberColor: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The customer's loyalty standing for this business — null only if no loyalty programme has ever been configured (distinct from "configured but disabled", which still returns a summary with isEnabled: false). */
|
/** The customer's loyalty standing for this business — null only if no loyalty programme has ever been configured (distinct from "configured but disabled", which still returns a summary with isEnabled: false). */
|
||||||
@@ -79,6 +91,7 @@ export async function getLoyaltySummary(
|
|||||||
accountId: account?.id ?? null,
|
accountId: account?.id ?? null,
|
||||||
currentStamps: account?.currentStamps ?? 0,
|
currentStamps: account?.currentStamps ?? 0,
|
||||||
stampsRequired: programme.stampsRequired,
|
stampsRequired: programme.stampsRequired,
|
||||||
|
rewardPosition: programme.rewardPosition,
|
||||||
availableReward: account?.rewards[0]
|
availableReward: account?.rewards[0]
|
||||||
? {
|
? {
|
||||||
id: account.rewards[0].id,
|
id: account.rewards[0].id,
|
||||||
@@ -91,6 +104,13 @@ export async function getLoyaltySummary(
|
|||||||
stampIcon: programme.stampIcon,
|
stampIcon: programme.stampIcon,
|
||||||
cardBackgroundColor: programme.cardBackgroundColor,
|
cardBackgroundColor: programme.cardBackgroundColor,
|
||||||
cardBackgroundImageUrl: programme.cardBackgroundImageUrl,
|
cardBackgroundImageUrl: programme.cardBackgroundImageUrl,
|
||||||
|
termsLine: programme.termsLine,
|
||||||
|
cardTopRightText: programme.cardTopRightText,
|
||||||
|
cardFont: programme.cardFont,
|
||||||
|
cardTopRightTextSize: programme.cardTopRightTextSize,
|
||||||
|
cardTopRightTextColor: programme.cardTopRightTextColor,
|
||||||
|
cardBorderColor: programme.cardBorderColor,
|
||||||
|
cardMemberNumberColor: programme.cardMemberNumberColor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +144,8 @@ export async function getAvailableRewardFlags(
|
|||||||
|
|
||||||
export interface CustomerLoyaltyBrief {
|
export interface CustomerLoyaltyBrief {
|
||||||
currentStamps: number;
|
currentStamps: number;
|
||||||
stampsRequired: number;
|
/** How many stamps actually earn the reward — see resolveRewardThreshold. */
|
||||||
|
rewardThreshold: number;
|
||||||
hasAvailableReward: boolean;
|
hasAvailableReward: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +170,13 @@ export async function getLoyaltySummariesForCustomers(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rewardThreshold = resolveRewardThreshold(programme);
|
||||||
return new Map(
|
return new Map(
|
||||||
accounts.map((a) => [
|
accounts.map((a) => [
|
||||||
a.customerId,
|
a.customerId,
|
||||||
{
|
{
|
||||||
currentStamps: a.currentStamps,
|
currentStamps: a.currentStamps,
|
||||||
stampsRequired: programme.stampsRequired,
|
rewardThreshold,
|
||||||
hasAvailableReward: a.rewards.length > 0,
|
hasAvailableReward: a.rewards.length > 0,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
@@ -404,7 +426,7 @@ async function addStampTransaction(
|
|||||||
|
|
||||||
let rewardCreated = false;
|
let rewardCreated = false;
|
||||||
let rewardId: string | undefined;
|
let rewardId: string | undefined;
|
||||||
if (account.currentStamps >= params.programme.stampsRequired) {
|
if (account.currentStamps >= resolveRewardThreshold(params.programme)) {
|
||||||
const existingAvailable = await tx.loyaltyReward.findFirst({
|
const existingAvailable = await tx.loyaltyReward.findFirst({
|
||||||
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
|
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
|
||||||
});
|
});
|
||||||
@@ -506,6 +528,8 @@ export async function getLoyaltyHistory(
|
|||||||
export interface ProgrammeConfigInput {
|
export interface ProgrammeConfigInput {
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
stampsRequired: number;
|
stampsRequired: number;
|
||||||
|
/** See resolveRewardThreshold's doc comment for what each mode means. */
|
||||||
|
rewardPosition: RewardPositionMode;
|
||||||
rewardType: "PERCENTAGE" | "FIXED_AMOUNT" | "FREE_SERVICE";
|
rewardType: "PERCENTAGE" | "FIXED_AMOUNT" | "FREE_SERVICE";
|
||||||
/** Decimal string — see ServiceCatalogueService's file header for why money is never a JS number. Required for PERCENTAGE/FIXED_AMOUNT, ignored for FREE_SERVICE. */
|
/** Decimal string — see ServiceCatalogueService's file header for why money is never a JS number. Required for PERCENTAGE/FIXED_AMOUNT, ignored for FREE_SERVICE. */
|
||||||
rewardValue?: string;
|
rewardValue?: string;
|
||||||
@@ -522,12 +546,19 @@ export interface ProgrammeConfigInput {
|
|||||||
cardBackgroundImageUrl?: string;
|
cardBackgroundImageUrl?: string;
|
||||||
stampIcon?: string;
|
stampIcon?: string;
|
||||||
termsLine?: string;
|
termsLine?: string;
|
||||||
|
/** Free text shown in the card's top-right area — see schema.prisma's cardTopRightText doc comment. */
|
||||||
|
cardTopRightText?: string;
|
||||||
|
cardFont: LoyaltyCardFont;
|
||||||
|
cardTopRightTextSize: LoyaltyCardTextSize;
|
||||||
|
cardTopRightTextColor?: string;
|
||||||
|
cardBorderColor?: string;
|
||||||
|
cardMemberNumberColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only enforced when isEnabled — a half-configured, disabled draft is fine (the owner is still working on it); a half-configured, *enabled* programme would start awarding rewards nobody can redeem correctly. */
|
/** Only enforced when isEnabled — a half-configured, disabled draft is fine (the owner is still working on it); a half-configured, *enabled* programme would start awarding rewards nobody can redeem correctly. */
|
||||||
export function validateProgrammeConfig(input: ProgrammeConfigInput): ValidationResult<LoyaltyErrorCode> {
|
export function validateProgrammeConfig(input: ProgrammeConfigInput): ValidationResult<LoyaltyErrorCode> {
|
||||||
if (!Number.isInteger(input.stampsRequired) || input.stampsRequired < 1) {
|
if (!Number.isInteger(input.stampsRequired) || input.stampsRequired < 1 || input.stampsRequired > MAX_STAMPS_REQUIRED) {
|
||||||
return fail({ code: "INVALID_INPUT", message: "Stamps required must be at least 1." });
|
return fail({ code: "INVALID_INPUT", message: `Stamps required must be between 1 and ${MAX_STAMPS_REQUIRED}.` });
|
||||||
}
|
}
|
||||||
if (!input.isEnabled) return ok();
|
if (!input.isEnabled) return ok();
|
||||||
|
|
||||||
@@ -580,6 +611,7 @@ export async function updateProgrammeConfig(
|
|||||||
const data = {
|
const data = {
|
||||||
isEnabled: params.input.isEnabled,
|
isEnabled: params.input.isEnabled,
|
||||||
stampsRequired: params.input.stampsRequired,
|
stampsRequired: params.input.stampsRequired,
|
||||||
|
rewardPosition: params.input.rewardPosition,
|
||||||
rewardType: params.input.rewardType,
|
rewardType: params.input.rewardType,
|
||||||
rewardValue: params.input.rewardType === "FREE_SERVICE" ? null : (params.input.rewardValue ?? null),
|
rewardValue: params.input.rewardType === "FREE_SERVICE" ? null : (params.input.rewardValue ?? null),
|
||||||
rewardLabel: params.input.rewardLabel?.trim() || null,
|
rewardLabel: params.input.rewardLabel?.trim() || null,
|
||||||
@@ -595,6 +627,12 @@ export async function updateProgrammeConfig(
|
|||||||
cardBackgroundImageUrl: params.input.cardBackgroundImageUrl || null,
|
cardBackgroundImageUrl: params.input.cardBackgroundImageUrl || null,
|
||||||
stampIcon: params.input.stampIcon || null,
|
stampIcon: params.input.stampIcon || null,
|
||||||
termsLine: params.input.termsLine?.trim() || null,
|
termsLine: params.input.termsLine?.trim() || null,
|
||||||
|
cardTopRightText: params.input.cardTopRightText?.trim() || null,
|
||||||
|
cardFont: params.input.cardFont,
|
||||||
|
cardTopRightTextSize: params.input.cardTopRightTextSize,
|
||||||
|
cardTopRightTextColor: params.input.cardTopRightTextColor || null,
|
||||||
|
cardBorderColor: params.input.cardBorderColor || null,
|
||||||
|
cardMemberNumberColor: params.input.cardMemberNumberColor || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
await prisma.loyaltyProgramme.upsert({
|
await prisma.loyaltyProgramme.upsert({
|
||||||
@@ -745,7 +783,11 @@ export async function redeemReward(
|
|||||||
discountAmount = Math.min(Math.max(discountAmount, 0), subtotal);
|
discountAmount = Math.min(Math.max(discountAmount, 0), subtotal);
|
||||||
const discountAmountString = discountAmount.toFixed(2);
|
const discountAmountString = discountAmount.toFixed(2);
|
||||||
|
|
||||||
const stampsRequired = reward.loyaltyAccount.loyaltyProgramme.stampsRequired;
|
// Deduct however many stamps actually earned this reward, not the raw
|
||||||
|
// stampsRequired total — in FINAL_SLOT_IS_REWARD mode those aren't the
|
||||||
|
// same number (see resolveRewardThreshold), and the ledger must only
|
||||||
|
// ever remove what was really collected.
|
||||||
|
const rewardThreshold = resolveRewardThreshold(reward.loyaltyAccount.loyaltyProgramme);
|
||||||
|
|
||||||
const { alreadyRedeemed } = await prisma.$transaction(async (tx) => {
|
const { alreadyRedeemed } = await prisma.$transaction(async (tx) => {
|
||||||
const updated = await tx.loyaltyReward.updateMany({
|
const updated = await tx.loyaltyReward.updateMany({
|
||||||
@@ -760,7 +802,7 @@ export async function redeemReward(
|
|||||||
if (updated.count === 0) return { alreadyRedeemed: true };
|
if (updated.count === 0) return { alreadyRedeemed: true };
|
||||||
|
|
||||||
const account = await tx.loyaltyAccount.findUniqueOrThrow({ where: { id: reward.loyaltyAccountId } });
|
const account = await tx.loyaltyAccount.findUniqueOrThrow({ where: { id: reward.loyaltyAccountId } });
|
||||||
const clampedDeduction = Math.min(stampsRequired, account.currentStamps);
|
const clampedDeduction = Math.min(rewardThreshold, account.currentStamps);
|
||||||
await tx.loyaltyAccount.update({
|
await tx.loyaltyAccount.update({
|
||||||
where: { id: account.id },
|
where: { id: account.id },
|
||||||
data: { currentStamps: { decrement: clampedDeduction } },
|
data: { currentStamps: { decrement: clampedDeduction } },
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { LoyaltyProgramme } from "@/generated/prisma/client";
|
||||||
|
import { RewardPositionMode } from "@/generated/prisma/enums";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamps required for a reward is capped at 10 (Phase 10 follow-up brief) —
|
||||||
|
* mirrored by the LoyaltyProgramme_stampsRequired_range CHECK constraint,
|
||||||
|
* validateProgrammeConfig, and the owner form's own client-side validation.
|
||||||
|
* Lives here (not loyalty.service.ts) for the same reason
|
||||||
|
* resolveRewardThreshold does — see that function's doc comment.
|
||||||
|
*/
|
||||||
|
export const MAX_STAMPS_REQUIRED = 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single place "how many stamps actually earn the reward" is decided —
|
||||||
|
* every caller that needs to know (auto/manual stamp awarding, redemption,
|
||||||
|
* progress text, the customer card, the owner's live preview) reads through
|
||||||
|
* this rather than each re-deriving its own answer, so they can't disagree.
|
||||||
|
* Deliberately its own tiny, side-effect-free module (not defined inline in
|
||||||
|
* loyalty.service.ts) so client components — the customer card, the owner's
|
||||||
|
* live preview — can import this one pure function without pulling in the
|
||||||
|
* rest of that file's Prisma-calling service code.
|
||||||
|
*
|
||||||
|
* AFTER_ALL_STAMPS: every one of `stampsRequired` positions is a normal
|
||||||
|
* stamp; the reward unlocks once all of them are filled — threshold ==
|
||||||
|
* stampsRequired.
|
||||||
|
*
|
||||||
|
* FINAL_SLOT_IS_REWARD: the last of the `stampsRequired` positions on the
|
||||||
|
* card *is* the reward, not a stamp to collect — so it's earned one stamp
|
||||||
|
* early, at stampsRequired - 1. (Clamped at 1 so a stampsRequired of 1 can
|
||||||
|
* never produce a threshold of 0.)
|
||||||
|
*/
|
||||||
|
export function resolveRewardThreshold(
|
||||||
|
programme: Pick<LoyaltyProgramme, "stampsRequired" | "rewardPosition">,
|
||||||
|
): number {
|
||||||
|
return programme.rewardPosition === RewardPositionMode.FINAL_SLOT_IS_REWARD
|
||||||
|
? Math.max(1, programme.stampsRequired - 1)
|
||||||
|
: programme.stampsRequired;
|
||||||
|
}
|
||||||
@@ -218,6 +218,7 @@ export async function getConfigurationWarnings(prisma: PrismaClient, businessId:
|
|||||||
const validation = validateProgrammeConfig({
|
const validation = validateProgrammeConfig({
|
||||||
isEnabled: loyaltyProgramme.isEnabled,
|
isEnabled: loyaltyProgramme.isEnabled,
|
||||||
stampsRequired: loyaltyProgramme.stampsRequired,
|
stampsRequired: loyaltyProgramme.stampsRequired,
|
||||||
|
rewardPosition: loyaltyProgramme.rewardPosition,
|
||||||
rewardType: loyaltyProgramme.rewardType,
|
rewardType: loyaltyProgramme.rewardType,
|
||||||
rewardValue: loyaltyProgramme.rewardValue?.toString(),
|
rewardValue: loyaltyProgramme.rewardValue?.toString(),
|
||||||
freeServiceId: loyaltyProgramme.freeServiceId ?? undefined,
|
freeServiceId: loyaltyProgramme.freeServiceId ?? undefined,
|
||||||
@@ -225,6 +226,8 @@ export async function getConfigurationWarnings(prisma: PrismaClient, businessId:
|
|||||||
requiresStaffApproval: loyaltyProgramme.requiresStaffApproval,
|
requiresStaffApproval: loyaltyProgramme.requiresStaffApproval,
|
||||||
earnOnCancelled: loyaltyProgramme.earnOnCancelled,
|
earnOnCancelled: loyaltyProgramme.earnOnCancelled,
|
||||||
earnOnNoShow: loyaltyProgramme.earnOnNoShow,
|
earnOnNoShow: loyaltyProgramme.earnOnNoShow,
|
||||||
|
cardFont: loyaltyProgramme.cardFont,
|
||||||
|
cardTopRightTextSize: loyaltyProgramme.cardTopRightTextSize,
|
||||||
});
|
});
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
|
|||||||
Reference in new issue
Block a user