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:
Stefan Lazic committed 2026-08-05 09:25:59 +01:00
1 parent 9e39908ea7
commit d3bbad09c6
74 files changed
+1622 -195

No files matched your search

+9 -6
View File
@@ -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
+13 -6
View File
@@ -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
+372
View File
@@ -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.
+75
View File
@@ -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",
},
});
}); });
}); });
+25 -5
View File
@@ -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();
@@ -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/%';
+51 -2
View File
@@ -1143,14 +1143,49 @@ 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())
businessId String @unique businessId String @unique
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)
stampsRequired Int @default(10) /// Capped at 10 (see the CHECK constraint added in migration
/// 20260805000000_phase10_loyalty_card_and_permission_fixes) — enforced
/// identically in validateProgrammeConfig, the owner form, and here.
stampsRequired Int @default(10)
/// See RewardPositionMode's own doc comment.
rewardPosition RewardPositionMode @default(AFTER_ALL_STAMPS)
rewardType RewardType @default(PERCENTAGE) 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
View File
@@ -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>
+6 -5
View File
@@ -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>
+14 -3
View File
@@ -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>
+1 -1
View File
@@ -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>
+4 -4
View File
@@ -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 (
+3 -3
View File
@@ -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>
+2 -2
View File
@@ -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 &ldquo;{q}&rdquo;. No staff match &ldquo;{q}&rdquo;.
</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>
+2 -1
View File
@@ -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>
)} )}
+2 -2
View File
@@ -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>
+2 -1
View File
@@ -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>
)} )}
+1 -1
View File
@@ -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>
+3 -3
View File
@@ -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);
});
});
+41
View File
@@ -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),
},
});
}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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 &ldquo;{q}&rdquo;. No customers match &ldquo;{q}&rdquo;.
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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}
/> />
); );
+3 -3
View File
@@ -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>
+74 -14
View File
@@ -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">
Member #{memberNumber} {cardTopRightText && (
</span> <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}
</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
? "bg-accent-loyalty scale-[1.08] shadow-lg" ? earned
? "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 ? (
<span className="text-ink text-center text-[7px] leading-tight font-black">{rewardLabel}</span> earned ? (
<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>
+1 -1
View File
@@ -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,
)} )}
> >
+52 -1
View File
@@ -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 (
+5
View File
@@ -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>
+5 -10
View File
@@ -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 &amp; Contact</h3> <h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Social &amp; 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>
<button <div className="flex gap-2">
type="button" {(
onClick={() => setScope((s) => (s === "category" ? "all" : "category"))} [
className="border-line shrink-0 rounded-xl border bg-white px-3 py-2.5 text-xs font-bold tracking-widest uppercase" { value: "all", label: "All Images" },
> { value: "category", label: "This Field Only" },
{scope === "category" ? "This field" : "All images"} ] as const
</button> ).map((option) => (
<button
key={option.value}
type="button"
onClick={() => setScope(option.value)}
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",
)}
>
{option.label}
</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"
}`} }`}
> >
+35 -7
View File
@@ -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&apos;t wired up yet — copy this invite link and send it to them yourself. It lets them Email delivery isn&apos;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 &amp; Rescheduling</h3> <h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Cancellation &amp; 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 &amp; Address</h3> <h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Contact &amp; 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 &amp; Expiry</h3> <h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules &amp; 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&apos;t set a password yet. Email delivery isn&apos;t wired up — send them the link This person hasn&apos;t set a password yet. Email delivery isn&apos;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 &amp; Permissions</h3> <h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Role &amp; 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&apos;t cancel them — they&apos;ll need to {deactivateConflicts.length > 1 ? "s" : ""}. Deactivating won&apos;t cancel them — they&apos;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&apos;d like to hear about upcoming appointment reminders. optional. Choose how you&apos;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">
-2
View File
@@ -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();
});
});
+96 -16
View File
@@ -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);
+49 -7
View File
@@ -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 } },
+38
View File
@@ -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({