Post Phase 8 - Fixes 1
Delivered: Super Administrator — env-var provisioned, no StaffProfile at all (structural exclusion from Team/barber-picker/appointments/staff-totals, not a filter). hasPermission short-circuit + new getActingBusinessContext() helper retrofitted across 16 owner pages so it can actually manage the business. Media Manager — one upload/library/external-URL modal + field picker, backing every image field app-wide. Local-disk storage + Docker volume, SVG rejected, size-capped. Welcome backgrounds — desktop/mobile images with CSS breakpoint swap. Business Branding settings — logo/cover/welcome/favicon/icon/colours all editable post-install; cover image finally rendered (was dead config). Service descriptions — now surfaced on Home and in the booking flow. Loyalty card — live preview, ColorPicker/MediaPicker/IconPicker, flip removed for a static premium-styled card, Loyalty added to bottom nav. Fixed a real pre-existing bug where the card's configured colour/image/icon were stored but never actually rendered. Home Screen Redesign — hero gallery + sticky Services/Portfolio/Reviews/Details tabs, backed by a new unified gallery model/service (no hero/portfolio duplication). Details tab surfaces social links and a map that were never shown before. Google Reviews — real sync pipeline (24h cron, CRON_SECRET-gated, mirrors the existing notification cron exactly), DB-only customer reads, owner hide/unhide + policy management page. Architecture cleanup — SettingsPageShell extracted from 6 duplicated page wrappers; fixed a second pre-existing bug where branding image fields could never actually be cleared once set. Verification: 418 tests passing (236 unit + 182 integration, all against a real MySQL database), full typecheck/lint clean across src, and a real Docker build+run cycle — which caught and fixed a genuine bug (scripts/ wasn't copied into the production image, so Super Admin sync would have crashed on every real deployment).
This commit is contained in:
79 files changed
+4121
-450
No files matched your search
@@ -15,6 +15,16 @@ AUTH_GOOGLE_SECRET=""
|
||||
# Base URL of the app (used for callback URLs, email links, etc.)
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
|
||||
# Media Manager (Phase 9) — max upload size in bytes. Defaults to
|
||||
# 5,000,000 (5MB) if unset. See src/lib/media/media-storage.ts.
|
||||
MEDIA_MAX_UPLOAD_BYTES="5000000"
|
||||
|
||||
# Google Reviews (Phase 9) — from https://console.cloud.google.com/apis/credentials,
|
||||
# needs the Places API enabled. Leave unset to keep review sync disabled
|
||||
# (Home shows clearly-labelled demo reviews instead). See
|
||||
# src/services/reviews/review-sync.service.ts and /owner/settings/reviews.
|
||||
GOOGLE_PLACES_API_KEY=""
|
||||
|
||||
# Email (Phase 7) — see src/services/notifications/providers.
|
||||
# "console" logs would-be emails to the server console instead of sending
|
||||
# (safe default — never send real email from an unconfigured environment).
|
||||
@@ -52,6 +62,18 @@ CRON_SECRET=""
|
||||
# openssl rand -hex 32
|
||||
INSTALLATION_TOKEN=""
|
||||
|
||||
# Super Administrator (Phase 9) — platform-level account with every Owner
|
||||
# permission, provisioned from these env vars rather than the installation
|
||||
# wizard (see scripts/ensure-super-admin.ts, run on every container start).
|
||||
# Has no StaffProfile by design: never appears in Team, the customer-facing
|
||||
# barber picker, or staff totals, and can never receive an appointment. All
|
||||
# four optional — leave SUPER_ADMIN_EMAIL unset to skip provisioning one
|
||||
# entirely. Re-run to rotate the password (re-hashed every run).
|
||||
SUPER_ADMIN_EMAIL=""
|
||||
SUPER_ADMIN_PASSWORD=""
|
||||
SUPER_ADMIN_FIRST_NAME=""
|
||||
SUPER_ADMIN_LAST_NAME=""
|
||||
|
||||
# Staff Google Calendar sync (Phase 8) — separate OAuth client from
|
||||
# AUTH_GOOGLE_ID/SECRET above (different scopes; see
|
||||
# src/services/calendar/google-calendar.provider.ts). Leave unset to keep
|
||||
|
||||
@@ -53,12 +53,19 @@ COPY --from=builder --chown=nextchair:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/generated ./generated
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/prisma ./prisma
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/src ./src
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/scripts ./scripts
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/package.json ./package.json
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/prisma.config.ts ./prisma.config.ts
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/next.config.ts ./next.config.ts
|
||||
COPY --from=builder --chown=nextchair:nodejs /app/tsconfig.json ./tsconfig.json
|
||||
COPY --chown=nextchair:nodejs docker/entrypoint.sh ./docker/entrypoint.sh
|
||||
RUN chmod +x ./docker/entrypoint.sh
|
||||
# Media Manager uploads (Phase 9) — public/media is a named volume mount
|
||||
# point (see docker-compose.local.yaml). Creating it here, owned by the
|
||||
# non-root user, before that volume is first attached means Docker
|
||||
# initializes the volume with this ownership rather than root's — see
|
||||
# src/lib/media/media-storage.ts.
|
||||
RUN mkdir -p ./public/media && chown nextchair:nodejs ./public/media
|
||||
|
||||
USER nextchair
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -44,6 +44,13 @@ services:
|
||||
- .env.local
|
||||
ports:
|
||||
- "${APP_HOST_PORT:-3000}:3000"
|
||||
volumes:
|
||||
# Media Manager uploads (Phase 9) — served directly by Next's own
|
||||
# static handling for public/, so no separate storage service is
|
||||
# needed in this deployment model; this volume is what makes an
|
||||
# uploaded file survive `docker compose down`/a rebuild. See
|
||||
# src/lib/media/media-storage.ts.
|
||||
- next-chair-media:/app/public/media
|
||||
healthcheck:
|
||||
# No curl/wget in the node:22-alpine base image — a plain Node
|
||||
# request avoids adding a package just for this. See
|
||||
@@ -85,6 +92,8 @@ services:
|
||||
volumes:
|
||||
next-chair-mysql-data:
|
||||
name: next-chair-mysql-data
|
||||
next-chair-media:
|
||||
name: next-chair-media
|
||||
|
||||
networks:
|
||||
next-chair:
|
||||
|
||||
@@ -13,6 +13,12 @@ set -e
|
||||
echo "[entrypoint] Applying database migrations..."
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Optional — only provisions/syncs a Super Administrator when
|
||||
# SUPER_ADMIN_EMAIL is set (see scripts/ensure-super-admin.ts). Runs after
|
||||
# migrations so the User.isSuperAdmin column always exists by this point.
|
||||
echo "[entrypoint] Syncing Super Administrator..."
|
||||
npx tsx scripts/ensure-super-admin.ts
|
||||
|
||||
echo "[entrypoint] Starting application..."
|
||||
# `exec` + calling the `next` binary directly (not `npm run start`) so
|
||||
# the Next.js server process itself receives SIGTERM directly from
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Phase 9 — Super Administrator, Media Manager, Business Branding, Loyalty Redesign, Home Screen Redesign and Google Reviews: Summary
|
||||
|
||||
A large post-Phase-8 pass adding a platform-level administration account, a single reusable Media Manager that becomes the standard image picker everywhere, full post-install editability of every branding asset, a restyled and properly-configured loyalty card, a real Google Reviews pipeline, and a sticky-tabbed Home Screen redesign (Services/Portfolio/Reviews/Details) built on top of a new owner-managed gallery. Executed in nine dependency-ordered sub-phases (9.0–9.9, tracked as such in-session), each fully typechecked, linted, and integration-tested against a real MySQL database before moving to the next — no phase was left partially wired.
|
||||
|
||||
---
|
||||
|
||||
## 1. Super Administrator
|
||||
|
||||
A platform-level account with every Owner permission, provisioned from environment variables (`SUPER_ADMIN_EMAIL`/`PASSWORD`/`FIRST_NAME`/`LAST_NAME`) rather than the installation wizard — deliberately **not** a hidden staff member. It has no `StaffProfile` row at all, which is the whole design: every exclusion the brief asked for (not in the Team list, not selectable as a barber, never receives an appointment, not counted in staff totals, not in customer-facing staff lists) falls out **structurally** from that absence — `StaffProfile`-scoped queries simply never see it — rather than needing a filter added at each of the ~6 call sites that would otherwise need one.
|
||||
|
||||
`hasPermission()` (`src/lib/auth/permissions.ts`) short-circuits `true` for any permission when `User.isSuperAdmin` is set — safe because `OWNER_PERMISSIONS` already **is** the full `Permission` enum, so "all Owner permissions" and "unconditionally true" are the same thing. `scripts/ensure-super-admin.ts` is idempotent and re-hashes the password every run (wired into `docker/entrypoint.sh`, after migrations), so rotating `SUPER_ADMIN_PASSWORD` and restarting the container is how you change it; it refuses to silently take over an existing non-super-admin account with the same email.
|
||||
|
||||
**Real architectural cost, paid honestly rather than papered over**: every owner page that resolved its business via `getActingStaff()` (a `StaffProfile` lookup) would otherwise 403 a Super Administrator with no such profile. Rather than special-case each one, a new `getActingBusinessContext()` helper (`src/lib/business/get-acting-business-context.ts`) resolves `{userId, businessId, businessName, businessTimezone}` for **either** a real staff member **or** a Super Administrator (falling back to the app's one business, since v1 is single-business) — and all 16 owner pages/actions that only ever needed `businessId`/`timezone` from `getActingStaff()`, not real staff identity, were retrofitted onto it (verified per-file that none of them used `staff.id` for anything beyond the redirect gate). Pages doing genuine staff-ledger work (loyalty stamp authorship, appointment reassignment) still require real `StaffProfile` identity and are out of scope for this account by design — "purely for administration," per the brief.
|
||||
|
||||
---
|
||||
|
||||
## 2. Media Manager
|
||||
|
||||
No upload mechanism existed anywhere before this phase — every image field was a plain-text URL `Input`. Now there is exactly one: `MediaManagerModal` (Upload / Library / External URL tabs) plus the field-level `MediaPicker` wrapper (preview + Change + Remove), both in `src/features/media/components/`. It backs every image field migrated this phase: business logo, cover image, welcome backgrounds, favicon, PWA icon, loyalty card background, staff photo, service photo, and the new gallery/portfolio system.
|
||||
|
||||
- **Storage**: local disk under `public/media/<category>/...`, served for free by Next's own static file handling — no custom GET route needed, only the upload path (`src/lib/media/media-storage.ts`). A deliberate v1 choice for this app's single-container Docker model, not a cloud integration; `docker-compose.local.yaml` gained a `next-chair-media` named volume so uploads survive a rebuild, and the Dockerfile pre-creates `public/media` owned by the non-root user before that volume first attaches.
|
||||
- **Schema**: new `MediaAsset` model (url, dimensions, size, mime type, category, uploader).
|
||||
- **Upload path**: a Server Action (`src/features/media/actions/media.ts`), not a REST route — Next.js Server Actions accept `FormData`/`File` natively, so this stays consistent with every other mutation in the app rather than introducing a one-off API endpoint. SVG is rejected (stored-XSS risk via inline `<script>`); size capped at `MEDIA_MAX_UPLOAD_BYTES` (default 5MB); dimensions probed with the tiny, dependency-free `image-size` package rather than pulling `sharp`/libvips into the Alpine build.
|
||||
- **Permissions, deliberately split**: uploading/browsing only requires being signed-in staff (or the Super Administrator) — a barber needs to add their own profile photo without an owner-only permission blocking them. **Deleting** an asset from the shared library requires the new `MANAGE_MEDIA_LIBRARY` permission (Owner/Super Admin), since a delete can affect images other fields/other staff reference.
|
||||
- **Icon and colour pickers**, built alongside since the loyalty card designer needed both: `ColorPicker` (native swatch + hex text + presets) and `IconPicker` (searchable Font Awesome grid — a **curated** ~40-icon set via individual named imports, not the full ~2000-icon `fas` pack, both for a more relevant picker and to keep Font Awesome's path data out of bundles that don't need it).
|
||||
|
||||
---
|
||||
|
||||
## 3. Welcome Background Images
|
||||
|
||||
`Business.welcomeBackgroundDesktopUrl`/`welcomeBackgroundMobileUrl` — independent images (not one image cropped two ways), editable via two `MediaPicker` fields in Business Branding settings. The Welcome page (`src/app/page.tsx`) swaps between them with a plain Tailwind `hidden md:block` / `block md:hidden` pair of absolutely-positioned layers plus a bottom gradient scrim for text legibility, falling back to the existing plain `bg-ink` background when neither is set.
|
||||
|
||||
---
|
||||
|
||||
## 4. Business Branding Settings
|
||||
|
||||
Every asset configured during installation is now editable afterwards from one place (`/owner/settings/business`, "Branding" section): logo, cover image, welcome backgrounds, favicon, PWA icon, theme colour, background colour. `BusinessProfileInput`'s image/colour fields were changed from `string | undefined` to `string | null`, and are now always included on the update payload (rather than the previous `value || undefined` pattern) — this was a real, if minor, pre-existing gap: the old pattern made it structurally impossible to ever clear `logoUrl` back to empty once set, since `undefined` tells Prisma "don't touch this field," not "clear it." Fixed as part of migrating `logoUrl` onto `MediaPicker`, whose Remove button needs to actually clear the field.
|
||||
|
||||
Cover image was configured at install but **never rendered anywhere** — confirmed dead config. It now renders on the Home hero (falling back to the logo, then a plain background) and, once the owner adds Hero gallery images (§7), those take priority over it — a genuine fallback chain, not a value silently overridden.
|
||||
|
||||
---
|
||||
|
||||
## 5. Service Descriptions
|
||||
|
||||
`Service.description` already existed in the schema and was already fetched in the owner catalogue — it was simply never selected by the customer-facing `getAvailableServices()`/`ServiceSummary`/`AvailableService` types. Added to both, plus a collapsing (renders nothing when null/empty) paragraph in `ServiceCard` and the booking flow's `ServiceSelector`. The cheapest, safest change in the phase; verified end-to-end against real seeded data before moving on.
|
||||
|
||||
---
|
||||
|
||||
## 6. Loyalty Card Improvements
|
||||
|
||||
**A second real, if smaller, pre-existing gap fixed here**: `LoyaltyProgramme.cardBackgroundColor`/`cardBackgroundImageUrl`/`stampIcon` were stored and editable in the owner designer, but `LoyaltyCard` (the actual rendered component) never accepted or used any of the three — every business's card looked identical regardless of configuration. `getLoyaltySummary()` was also missing `cardBackgroundImageUrl` from its return type. Both fixed; verified end-to-end (owner sets background colour/image/icon → same values reach `getLoyaltySummary` → same values now genuinely change what `LoyaltyCard` renders).
|
||||
|
||||
- **Designer**: live preview (an actual `<LoyaltyCard>` rendered inline with the in-progress, unsaved form state), `ColorPicker` for background colour, `MediaPicker` for background image, `IconPicker` for the stamp icon (replacing free-text — `findCatalogIcon()` falls back gracefully for any legacy free-text value that doesn't match the curated set).
|
||||
- **Customer card**: the 3D flip (front stamps / back a generic branding panel) is gone — `LoyaltyCard` is now a single static face, restyled with a CSS-only diagonal shine gradient and layered shadow for a "premium plastic card" feel, no extra image asset, so it stays cheap to render.
|
||||
- **Navigation**: `(customer)/layout.tsx`'s bottom nav is now Home / Bookings / **Loyalty** / Profile (the `/settings` tab relabelled "Profile" to match; route unchanged).
|
||||
|
||||
---
|
||||
|
||||
## 7. Home Screen Redesign
|
||||
|
||||
The largest single piece, built last since it consumes almost everything above. Replaces the old flat, single-scroll Home page with: an owner-managed **hero gallery** (falls back gracefully all the way down to cover image → logo → plain background if empty) matching content width rather than bleeding edge-to-edge, followed by sticky **Services / Portfolio / Reviews / Details** tabs (`src/features/home/components/home-tabs.tsx`, built on the existing Radix `Tabs` primitive with `position: sticky`).
|
||||
|
||||
- **Services tab**: the full catalogue (not a 4-item preview), grouped by category, with a live client-side search box and category filter pills — all client-side state over data already fetched once server-side, not a network round trip per keystroke.
|
||||
- **Portfolio tab**: owner-managed images with category badge filter pills.
|
||||
- **Reviews tab**: the real (or demo-fallback) review data from §8, moved here instead of duplicating it on the flat page.
|
||||
- **Details tab**: About Us, Staff, Opening Hours, and Social/Contact (website/Instagram/Facebook were fields on `Business` that were **never rendered anywhere customer-facing before** — confirmed and fixed here) — plus a map, via a **keyless** Google Maps embed iframe (`https://www.google.com/maps?q=<lat,lng or address>&output=embed`) using existing `BusinessLocation` coordinates, falling back to the street address when coordinates aren't set. Zero new dependency, zero API key needed for the map specifically (distinct from the Places API key §8 needs for reviews).
|
||||
|
||||
**Consolidation, not duplication**: Home's hero gallery and the Portfolio tab are powered by **one** new model (`BusinessGalleryImage`, discriminated by a `placement: HERO | PORTFOLIO` enum) and **one** service (`src/services/gallery/gallery.service.ts`) — not two near-identical "gallery" features. One owner page, `/owner/settings/gallery`, manages both sections (add via the Media Manager, up/down reorder, delete, and — Portfolio only — a category tag per image).
|
||||
|
||||
---
|
||||
|
||||
## 8. Google Reviews
|
||||
|
||||
The pre-existing `ReviewsProvider` abstraction (`src/services/reviews/reviews.service.ts`, from Phase 4) was explicitly built for this moment — "a real Google Places/Business Profile integration can implement this same interface later without any caller changing" — and that's exactly what happened: a new `DbReviewsProvider` reads the `Review` table instead of demo data, and `getBusinessReviews()`'s call sites (Home) didn't change at all.
|
||||
|
||||
- **Schema**: `Business.googlePlaceId`/`googleRating`/`googleRatingCount` (the aggregate, tracked separately from individual reviews — see below), a new `Review` model (unique on `businessId`+`externalId` for idempotent upsert), and `BusinessSettings.reviewsMinRating`/`reviewsExcludeRatingOnly` (the owner-facing visibility policy).
|
||||
- **Provider**: `google-places.provider.ts` mirrors the existing `CalendarProvider` pattern deliberately — plain `fetch` against a documented REST contract (the legacy Place Details endpoint), no SDK, an injectable `MockReviewsProvider` for tests.
|
||||
- **Sync**: a 24-hour scheduled job (`POST /api/cron/sync-google-reviews`, `CRON_SECRET`-gated exactly like the existing notification cron) is the **only** place that ever calls Google — `DbReviewsProvider` only ever reads what that job already wrote, satisfying "the customer application should always display reviews from the database" and "do not call the Places API on every page load" directly, not just in spirit. `DbReviewsProvider` falls back to demo data (clearly labelled `isDemoData: true`) only when a business has never had any reviews synced at all, so a fresh install's Reviews tab isn't empty.
|
||||
- **Owner management**: `/owner/settings/reviews` — Google Place ID + visibility policy (minimum rating, exclude rating-only reviews) reusing the existing `updateBusinessProfile`/`updateBusinessSettings` functions rather than a parallel update path, plus a hide/unhide review list with client-side rating filter and text search, and a manual "Sync Now" button for immediate feedback after first configuring a Place ID.
|
||||
- **Known, Google-side limitation** (not fixable from this codebase): Place Details only ever returns up to 5 "most relevant" individual reviews per call, regardless of how many the place actually has. The place-level aggregate rating/count is accurate and tracked separately; the individual review list is always a small sample. Using this feature for real also requires a billed Google Cloud project with the Places API enabled — real cost exposure worth being aware of before turning it on for a live business.
|
||||
|
||||
---
|
||||
|
||||
## Schema changes (all in one migration, `20260804134858_phase9_media_gallery_reviews_super_admin`)
|
||||
|
||||
- `Business`: `welcomeBackgroundDesktopUrl`, `welcomeBackgroundMobileUrl`, `googlePlaceId`, `googleRating`, `googleRatingCount`.
|
||||
- `BusinessSettings`: `reviewsMinRating`, `reviewsExcludeRatingOnly`.
|
||||
- `Service`: `imageUrl`.
|
||||
- New models: `MediaAsset`, `BusinessGalleryImage` (+ `GalleryPlacement` enum), `Review`.
|
||||
- New `Permission` values: `MANAGE_MEDIA_LIBRARY`, `MANAGE_REVIEWS` (both granted to Owner by default).
|
||||
- Same MySQL-collation fix required as Phase 8 (new tables default to `utf8mb4_unicode_ci`; hand-corrected to match the database's actual `utf8mb4_0900_ai_ci` before applying).
|
||||
|
||||
## New domain services
|
||||
|
||||
- `src/services/media/media.service.ts` + `src/lib/media/media-storage.ts`
|
||||
- `src/services/gallery/gallery.service.ts`
|
||||
- `src/services/reviews/google-places.provider.ts`, `review-sync.service.ts`, `review-management.service.ts`, `mock-reviews-provider.ts` (`reviews.service.ts` itself was extended, not replaced)
|
||||
- `src/lib/business/get-acting-business-context.ts`
|
||||
- `scripts/ensure-super-admin.ts`
|
||||
|
||||
## New reusable components
|
||||
|
||||
- `MediaManagerModal` / `MediaPicker` (`src/features/media/components/`)
|
||||
- `ColorPicker`, `IconPicker`, `ImageGallery` (`src/components/ui/`)
|
||||
- `SettingsPageShell` (`src/components/settings/`) — extracted once six owner settings pages shared the identical `TopNav` + centered-container wrapper verbatim
|
||||
- `HomeTabs` and its four tab sub-components (`src/features/home/components/home-tabs.tsx`)
|
||||
- `GalleryManager`, `ReviewsManager` (`src/features/owner/components/`)
|
||||
|
||||
## New scheduled jobs
|
||||
|
||||
- `POST /api/cron/sync-google-reviews` — every 24h, `CRON_SECRET`-gated, same shape as the existing notification cron.
|
||||
|
||||
## Architectural improvements made
|
||||
|
||||
- Structural (not filter-based) exclusion of the Super Administrator from every staff-scoped query, by giving it no `StaffProfile` at all.
|
||||
- One Media Manager, one Gallery model/service (Hero + Portfolio), one `ColorPicker`, one `IconPicker` — none duplicated per feature.
|
||||
- Two real, pre-existing gaps fixed as direct consequences of this work, not separately: `logoUrl` (and siblings) could never actually be cleared once set; `LoyaltyCard` silently ignored its own configured colour/image/icon.
|
||||
- `SettingsPageShell` extracted from six now-identical page wrappers.
|
||||
- Media upload permission split into "add/browse" (any staff) vs. "delete" (Owner/Super Admin only) rather than one coarse gate.
|
||||
|
||||
## Intentionally postponed
|
||||
|
||||
- S3-compatible object storage for the Media Manager (local disk + Docker volume for v1; swappable later behind the same storage-helper interface).
|
||||
- Full PWA manifest icon regeneration from one uploaded source image — no image-resize pipeline exists (deliberately avoided pulling `sharp`/libvips into the Docker build); the favicon/apple-touch-icon `<head>` tags do update from the uploaded image, the manifest's fixed icon set doesn't.
|
||||
- Drag-and-drop reordering for gallery/portfolio images (up/down buttons instead — a full DnD library was judged not worth it for this).
|
||||
- Price/duration range filters on the Home Services tab beyond category + text search.
|
||||
- Super Administrator performing genuine staff-ledger actions (loyalty stamp authorship, appointment reassignment) — it has no `StaffProfile` to attribute those to, by design.
|
||||
- A true single-service deep link from the Home Services tab's "Book" button into the booking flow (currently opens `/book` fresh, same as before this phase) — not asked for at that granularity.
|
||||
|
||||
## Future recommendations
|
||||
|
||||
- Swap local-disk media storage for S3-compatible object storage behind `media-storage.ts`'s existing interface once running more than one app container.
|
||||
- A real image-resize pipeline (`sharp`/Squoosh) for full multi-size PWA icon generation from one uploaded source image.
|
||||
- If Google's 5-review-per-call cap becomes limiting, investigate the newer Places API (v1) or a paid third-party reviews aggregator as an alternative data source behind the same `ReviewsFetchProvider` interface.
|
||||
- Drag-and-drop gallery/portfolio reordering.
|
||||
- Extend the Super Administrator with a dedicated `/admin` platform-level area if multi-business support is ever added — the schema (`businessId` on every model) already anticipates it.
|
||||
Generated
+62
@@ -10,6 +10,9 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.3",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.3.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.3.1",
|
||||
"@fortawesome/react-fontawesome": "^3.5.0",
|
||||
"@hookform/resolvers": "^5.7.1",
|
||||
"@prisma/adapter-mariadb": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
@@ -19,6 +22,7 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"image-size": "^2.0.2",
|
||||
"lucide-react": "^1.28.0",
|
||||
"luxon": "^3.7.2",
|
||||
"mariadb": "^3.5.3",
|
||||
@@ -1056,6 +1060,52 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-common-types": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.3.1.tgz",
|
||||
"integrity": "sha512-k0C0sdHmZtAo6dRDtd1Z/qcpyHbL0CKsjV8seMY/21xGhY5Wsv0XRmiI/xEEH4y2c9b1+jvgNs/3EqhV27yUEA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/fontawesome-svg-core": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.3.1.tgz",
|
||||
"integrity": "sha512-BoxVN3PKnMbgStHhjoaky/oWdxHomDqmBVA24IA3KEmssFGeI7u9YT/BJceOjIum/t6TpPa/vcMKaVYQeIQ/3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "7.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/free-solid-svg-icons": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.3.1.tgz",
|
||||
"integrity": "sha512-v0BLa0eqg7ubvVWeNSHVBs8fWH/GJicERZoJaxJ3FE/lj67VSqzoMg9pzfZVOfLMX10y0pGwQAuxoRVVH2patg==",
|
||||
"license": "(CC-BY-4.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-common-types": "7.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@fortawesome/react-fontawesome": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-3.5.0.tgz",
|
||||
"integrity": "sha512-63mlRr6fiBbJ0wjr1Cf6dsDGtP2lNvk9lnatKgxs/fIkhslsZT291hIUzJkuUkI9yr69ZvWnWfgb2qXm4QyVaA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "~6 || ~7",
|
||||
"react": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.7.1.tgz",
|
||||
@@ -7280,6 +7330,18 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/image-size": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
|
||||
"integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"image-size": "bin/image-size.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.11.3",
|
||||
"@fortawesome/fontawesome-svg-core": "^7.3.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.3.1",
|
||||
"@fortawesome/react-fontawesome": "^3.5.0",
|
||||
"@hookform/resolvers": "^5.7.1",
|
||||
"@prisma/adapter-mariadb": "^7.9.1",
|
||||
"@prisma/client": "^7.9.1",
|
||||
@@ -37,6 +40,7 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"image-size": "^2.0.2",
|
||||
"lucide-react": "^1.28.0",
|
||||
"luxon": "^3.7.2",
|
||||
"mariadb": "^3.5.3",
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `Business` ADD COLUMN `googlePlaceId` VARCHAR(191) NULL,
|
||||
ADD COLUMN `googleRating` DECIMAL(2, 1) NULL,
|
||||
ADD COLUMN `googleRatingCount` INTEGER NULL,
|
||||
ADD COLUMN `welcomeBackgroundDesktopUrl` VARCHAR(191) NULL,
|
||||
ADD COLUMN `welcomeBackgroundMobileUrl` VARCHAR(191) NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `BusinessSettings` ADD COLUMN `reviewsExcludeRatingOnly` BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN `reviewsMinRating` INTEGER NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `RolePermission` MODIFY `permission` ENUM('MANAGE_BUSINESS_PROFILE', 'MANAGE_BOOKING_RULES', 'MANAGE_SERVICES', 'MANAGE_STAFF', 'MANAGE_LOYALTY_PROGRAMME', 'MANAGE_NOTIFICATION_SETTINGS', 'VIEW_ALL_STAFF_APPOINTMENTS', 'MANAGE_ALL_APPOINTMENTS', 'MANAGE_OWN_APPOINTMENTS', 'CHECK_IN_CUSTOMERS', 'MANAGE_LOYALTY_STAMPS', 'MANAGE_OWN_AVAILABILITY', 'VIEW_CUSTOMER_DIRECTORY', 'BOOK_ON_BEHALF_OF_CUSTOMER', 'VIEW_REPORTING', 'MANAGE_MEDIA_LIBRARY', 'MANAGE_REVIEWS') NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `Service` ADD COLUMN `imageUrl` VARCHAR(191) NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `MediaAsset` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`businessId` VARCHAR(191) NOT NULL,
|
||||
`url` VARCHAR(500) NOT NULL,
|
||||
`originalFilename` VARCHAR(191) NULL,
|
||||
`mimeType` VARCHAR(191) NOT NULL,
|
||||
`sizeBytes` INTEGER NOT NULL,
|
||||
`width` INTEGER NULL,
|
||||
`height` INTEGER NULL,
|
||||
`category` VARCHAR(191) NULL,
|
||||
`uploadedByUserId` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `MediaAsset_businessId_category_idx`(`businessId`, `category`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `BusinessGalleryImage` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`businessId` VARCHAR(191) NOT NULL,
|
||||
`placement` ENUM('HERO', 'PORTFOLIO') NOT NULL,
|
||||
`mediaAssetId` VARCHAR(191) NULL,
|
||||
`imageUrl` VARCHAR(500) NOT NULL,
|
||||
`category` VARCHAR(191) NULL,
|
||||
`caption` VARCHAR(191) NULL,
|
||||
`displayOrder` INTEGER NOT NULL DEFAULT 0,
|
||||
`isActive` BOOLEAN NOT NULL DEFAULT true,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
INDEX `BusinessGalleryImage_businessId_placement_displayOrder_idx`(`businessId`, `placement`, `displayOrder`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `Review` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`businessId` VARCHAR(191) NOT NULL,
|
||||
`externalId` VARCHAR(191) NOT NULL,
|
||||
`authorName` VARCHAR(191) NOT NULL,
|
||||
`authorPhotoUrl` VARCHAR(191) NULL,
|
||||
`rating` INTEGER NOT NULL,
|
||||
`text` TEXT NULL,
|
||||
`relativeTimeDescription` VARCHAR(191) NULL,
|
||||
`publishedAt` DATETIME(3) NULL,
|
||||
`isHidden` BOOLEAN NOT NULL DEFAULT false,
|
||||
`fetchedAt` DATETIME(3) NOT NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
INDEX `Review_businessId_isHidden_idx`(`businessId`, `isHidden`),
|
||||
UNIQUE INDEX `Review_businessId_externalId_key`(`businessId`, `externalId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `MediaAsset` ADD CONSTRAINT `MediaAsset_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `MediaAsset` ADD CONSTRAINT `MediaAsset_uploadedByUserId_fkey` FOREIGN KEY (`uploadedByUserId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `BusinessGalleryImage` ADD CONSTRAINT `BusinessGalleryImage_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `BusinessGalleryImage` ADD CONSTRAINT `BusinessGalleryImage_mediaAssetId_fkey` FOREIGN KEY (`mediaAssetId`) REFERENCES `MediaAsset`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Review` ADD CONSTRAINT `Review_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+180
-11
@@ -69,6 +69,22 @@ enum Permission {
|
||||
/// MANAGE_STAFF/MANAGE_BUSINESS_PROFILE so "can see aggregate business
|
||||
/// health" is its own grantable capability.
|
||||
VIEW_REPORTING
|
||||
/// Phase 9 — the Media Manager (upload/select/delete library assets),
|
||||
/// used by every image field across the app (branding, loyalty card,
|
||||
/// staff photos, gallery/portfolio). See src/services/media.
|
||||
MANAGE_MEDIA_LIBRARY
|
||||
/// Phase 9 — hide/unhide imported Google reviews and tune the
|
||||
/// visibility filter policy. Separate from MANAGE_BUSINESS_PROFILE since
|
||||
/// it's reviewing third-party content, not editing business data.
|
||||
MANAGE_REVIEWS
|
||||
}
|
||||
|
||||
/// Phase 9 — where a BusinessGalleryImage is displayed: the Home page hero
|
||||
/// gallery, or the Portfolio tab. One model/service backs both rather than
|
||||
/// two near-duplicate "gallery" features — see src/services/gallery.
|
||||
enum GalleryPlacement {
|
||||
HERO
|
||||
PORTFOLIO
|
||||
}
|
||||
|
||||
enum StaffStatus {
|
||||
@@ -224,30 +240,36 @@ enum CalendarSyncStatus {
|
||||
/// every other model hangs off `businessId` rather than assuming a
|
||||
/// singleton so multi-business support later is additive, not a rewrite.
|
||||
model Business {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
name String
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
name String
|
||||
/// Short form for tight spaces (PWA home-screen label, browser tab on
|
||||
/// narrow viewports) — falls back to a truncated `name` when unset, see
|
||||
/// src/lib/business/branding.ts.
|
||||
shortName String?
|
||||
description String? @db.Text
|
||||
logoUrl String?
|
||||
shortName String?
|
||||
description String? @db.Text
|
||||
logoUrl String?
|
||||
/// Wide banner image for the Welcome page / booking pages — distinct
|
||||
/// from `logoUrl` (square mark) and optional; falls back to a plain
|
||||
/// color background when unset.
|
||||
coverImageUrl String?
|
||||
coverImageUrl String?
|
||||
/// Falls back to the app's neutral favicon when unset — see
|
||||
/// src/app/icon.png and the branding fallback rules in
|
||||
/// src/lib/business/branding.ts.
|
||||
faviconUrl String?
|
||||
faviconUrl String?
|
||||
/// Used for the PWA manifest icon set — falls back to the same neutral
|
||||
/// app icon as the favicon when unset.
|
||||
appIconUrl String?
|
||||
appIconUrl String?
|
||||
/// Hex color, e.g. "#111b15" — PWA `theme_color` / manifest and
|
||||
/// browser chrome. Falls back to the app's own theme color when unset.
|
||||
themeColor String?
|
||||
backgroundColor String?
|
||||
themeColor String?
|
||||
backgroundColor String?
|
||||
/// Phase 9 — Welcome page background, desktop breakpoint. Falls back to
|
||||
/// the existing plain background when unset. See src/app/page.tsx.
|
||||
welcomeBackgroundDesktopUrl String?
|
||||
/// Phase 9 — Welcome page background, mobile breakpoint. Independent of
|
||||
/// the desktop image (a wide banner rarely crops well to portrait).
|
||||
welcomeBackgroundMobileUrl String?
|
||||
|
||||
phone String?
|
||||
email String?
|
||||
@@ -255,6 +277,20 @@ model Business {
|
||||
instagramUrl String?
|
||||
facebookUrl String?
|
||||
|
||||
/// Phase 9 — Google Places Place ID this business's reviews sync from.
|
||||
/// Null = review sync disabled for this business (see
|
||||
/// src/services/reviews/review-sync.service.ts). Not validated against a
|
||||
/// fixed format — Google Place IDs have no stable public regex.
|
||||
googlePlaceId String?
|
||||
/// Google's own place-level aggregate rating, distinct from the
|
||||
/// individual Review rows below (Google's API only ever returns a
|
||||
/// handful of "most relevant" individual reviews per call, but does
|
||||
/// return the true aggregate rating/count for the whole place) — synced
|
||||
/// by the same job. Used for the customer-facing star/count summary
|
||||
/// instead of averaging just the visible review sample.
|
||||
googleRating Decimal? @db.Decimal(2, 1)
|
||||
googleRatingCount Int?
|
||||
|
||||
/// IANA timezone, e.g. "Europe/London". All wall-clock scheduling math
|
||||
/// (working hours, breaks, booking rules) is interpreted in this zone.
|
||||
timezone String @default("Europe/London")
|
||||
@@ -278,6 +314,9 @@ model Business {
|
||||
staffInvitations StaffInvitation[]
|
||||
notifications Notification[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
mediaAssets MediaAsset[]
|
||||
galleryImages BusinessGalleryImage[]
|
||||
reviews Review[]
|
||||
}
|
||||
|
||||
/// Split from Business (rather than address fields on Business itself) so
|
||||
@@ -334,6 +373,14 @@ model BusinessSettings {
|
||||
bookingTerms String? @db.Text
|
||||
privacyPolicyUrl String?
|
||||
|
||||
/// Phase 9 — Google reviews visibility policy, applied on top of the
|
||||
/// per-review Review.isHidden flag. Null minRating = no floor.
|
||||
reviewsMinRating Int?
|
||||
/// A "rating-only" review is one with no text — excluding those is the
|
||||
/// same underlying check as "does this review have text", so this is
|
||||
/// the single field for it (no separate requireText flag).
|
||||
reviewsExcludeRatingOnly Boolean @default(false)
|
||||
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -376,6 +423,123 @@ model BookingRule {
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Media (Phase 9) — the single Media Manager library backing every image
|
||||
// field app-wide (branding, loyalty card, staff photos, service photos,
|
||||
// gallery/portfolio). Stored on local disk under public/media/ in this
|
||||
// deployment model (see src/services/media) — deliberately not a cloud
|
||||
// object-storage integration in v1, kept behind the same service so that's
|
||||
// swappable later without touching callers.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model MediaAsset {
|
||||
id String @id @default(cuid())
|
||||
|
||||
businessId String
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Public path under /media/... (served directly by Next's static file
|
||||
/// handling for public/ — see docker-compose.local.yaml's media volume).
|
||||
url String @db.VarChar(500)
|
||||
originalFilename String?
|
||||
mimeType String
|
||||
sizeBytes Int
|
||||
/// Best-effort, probed at upload time (see src/services/media) — null
|
||||
/// for anything the probe doesn't recognise, never blocks the upload.
|
||||
width Int?
|
||||
height Int?
|
||||
/// Freeform tag (e.g. "branding", "loyalty", "staff", "service",
|
||||
/// "portfolio") set by whichever field uploaded it — used to pre-filter
|
||||
/// the library grid, not an enum, so a new field category never needs a
|
||||
/// migration.
|
||||
category String?
|
||||
|
||||
uploadedByUserId String?
|
||||
uploadedByUser User? @relation("MediaAssetUploadedBy", fields: [uploadedByUserId], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
galleryImages BusinessGalleryImage[]
|
||||
|
||||
@@index([businessId, category])
|
||||
}
|
||||
|
||||
/// One row per image in either the Home hero gallery or the Portfolio tab
|
||||
/// — a single model/service for both rather than two near-duplicate
|
||||
/// gallery features (see src/services/gallery). `imageUrl` is denormalized
|
||||
/// (same "just a URL" pattern as every other image field in this schema)
|
||||
/// so deleting the underlying MediaAsset never breaks a live gallery row;
|
||||
/// `mediaAssetId` is only kept for the library UI to know which rows a
|
||||
/// given asset is used in.
|
||||
model BusinessGalleryImage {
|
||||
id String @id @default(cuid())
|
||||
|
||||
businessId String
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
|
||||
placement GalleryPlacement
|
||||
|
||||
mediaAssetId String?
|
||||
mediaAsset MediaAsset? @relation(fields: [mediaAssetId], references: [id], onDelete: SetNull)
|
||||
imageUrl String @db.VarChar(500)
|
||||
|
||||
/// Portfolio-only — powers the category badge filter pills. Ignored for
|
||||
/// HERO placement.
|
||||
category String?
|
||||
caption String?
|
||||
displayOrder Int @default(0)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([businessId, placement, displayOrder])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Reviews (Phase 9) — synced from Google Places, never fetched client-side
|
||||
// (see src/services/reviews/review-sync.service.ts, the 24h cron job at
|
||||
// /api/cron/sync-google-reviews). The pre-existing ReviewsProvider
|
||||
// abstraction (src/services/reviews/reviews.service.ts) reads from this
|
||||
// table instead of demo data; callers (Home) don't change.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model Review {
|
||||
id String @id @default(cuid())
|
||||
|
||||
businessId String
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// Google's own review id — the idempotency key for upsert-on-sync.
|
||||
externalId String
|
||||
|
||||
authorName String
|
||||
authorPhotoUrl String?
|
||||
rating Int
|
||||
text String? @db.Text
|
||||
/// Google's own pre-formatted "2 weeks ago" string — displayed as-is
|
||||
/// rather than recomputed, since Google doesn't return an exact
|
||||
/// timestamp for every review.
|
||||
relativeTimeDescription String?
|
||||
publishedAt DateTime?
|
||||
|
||||
/// Owner-controlled per-review visibility — see MANAGE_REVIEWS. Applied
|
||||
/// on top of BusinessSettings' reviewsMinRating/reviewsExcludeRatingOnly
|
||||
/// policy, not instead of it.
|
||||
isHidden Boolean @default(false)
|
||||
|
||||
/// When the sync job last saw this review — lets a future "remove
|
||||
/// reviews Google stopped returning" sweep distinguish stale rows
|
||||
/// without deleting anything today.
|
||||
fetchedAt DateTime
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([businessId, externalId])
|
||||
@@index([businessId, isHidden])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Installation
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -459,6 +623,9 @@ model User {
|
||||
createdAppointments Appointment[] @relation("AppointmentCreatedBy")
|
||||
/// Owner-side: invitations this user has sent (see StaffInvitation).
|
||||
sentStaffInvitations StaffInvitation[] @relation("StaffInvitationInvitedBy")
|
||||
/// Phase 9 — Media Manager uploads attributed to this user, purely for
|
||||
/// provenance (see MediaAsset). Not used for any permission check.
|
||||
uploadedMediaAssets MediaAsset[] @relation("MediaAssetUploadedBy")
|
||||
}
|
||||
|
||||
model Account {
|
||||
@@ -742,6 +909,8 @@ model Service {
|
||||
|
||||
name String
|
||||
description String? @db.Text
|
||||
/// Phase 9 — optional service photo, set via the Media Manager.
|
||||
imageUrl String?
|
||||
durationMinutes Int
|
||||
/// Standard SQL DECIMAL — supported natively on both Postgres and MySQL.
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Idempotent Super Administrator provisioning, run on every container
|
||||
* start (see docker/entrypoint.sh) — NOT part of the installation wizard,
|
||||
* per the brief's explicit requirement that this account come from
|
||||
* environment variables. Safe to run repeatedly: re-hashes and syncs the
|
||||
* password/name from the env vars every run, so rotating
|
||||
* SUPER_ADMIN_PASSWORD and restarting the container is how you change it.
|
||||
*
|
||||
* Deliberately does NOT create a StaffProfile — that's what keeps the
|
||||
* Super Administrator out of the Team list, the customer-facing barber
|
||||
* picker, and off the Appointment.staffId foreign key entirely, by
|
||||
* construction rather than a filter. See src/lib/auth/permissions.ts
|
||||
* (hasPermission's isSuperAdmin short-circuit) and
|
||||
* src/lib/business/get-acting-business-context.ts.
|
||||
*
|
||||
* Uses relative imports (not the "@/..." alias) and constructs its own
|
||||
* PrismaClient, matching prisma/seed.ts — both scripts run under `tsx`
|
||||
* outside Next's own module resolution/bundling.
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { PrismaClient } from "../generated/prisma/client";
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
|
||||
|
||||
const BCRYPT_COST = 12; // matches src/features/installation/installer.service.ts
|
||||
|
||||
async function main() {
|
||||
const email = process.env.SUPER_ADMIN_EMAIL?.trim().toLowerCase();
|
||||
if (!email) {
|
||||
console.log("[ensure-super-admin] SUPER_ADMIN_EMAIL not set — skipping (no Super Administrator configured).");
|
||||
return;
|
||||
}
|
||||
|
||||
const password = process.env.SUPER_ADMIN_PASSWORD;
|
||||
if (!password || password.length < 8) {
|
||||
console.error(
|
||||
"[ensure-super-admin] SUPER_ADMIN_EMAIL is set but SUPER_ADMIN_PASSWORD is missing or shorter than 8 characters. Refusing to provision a Super Administrator with no usable password.",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const firstName = process.env.SUPER_ADMIN_FIRST_NAME?.trim() ?? "";
|
||||
const lastName = process.env.SUPER_ADMIN_LAST_NAME?.trim() ?? "";
|
||||
const name = [firstName, lastName].filter(Boolean).join(" ") || null;
|
||||
|
||||
const adapter = new PrismaMariaDb(buildMysqlPoolConfig(process.env.DATABASE_URL));
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
try {
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_COST);
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
if (existing && !existing.isSuperAdmin) {
|
||||
console.error(
|
||||
`[ensure-super-admin] A user with email ${email} already exists and is not a Super Administrator. Refusing to overwrite it — use a different SUPER_ADMIN_EMAIL or remove the conflicting account first.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await prisma.user.update({
|
||||
where: { id: existing.id },
|
||||
data: { name, passwordHash, emailVerified: existing.emailVerified ?? new Date() },
|
||||
});
|
||||
console.log(`[ensure-super-admin] Synced credentials for existing Super Administrator (${email}).`);
|
||||
} else {
|
||||
await prisma.user.create({
|
||||
data: { email, name, passwordHash, isSuperAdmin: true, emailVerified: new Date() },
|
||||
});
|
||||
console.log(`[ensure-super-admin] Created Super Administrator (${email}).`);
|
||||
}
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[ensure-super-admin] Failed:", error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -3,18 +3,19 @@ import { Star } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
import { AppointmentCard } from "@/components/ui/appointment-card";
|
||||
import { BarberCard } from "@/components/ui/barber-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ServiceCard } from "@/components/ui/service-card";
|
||||
import { Weekday } from "@/generated/prisma/enums";
|
||||
import { ImageGallery } from "@/components/ui/image-gallery";
|
||||
import { GalleryPlacement, Weekday } from "@/generated/prisma/enums";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { computeOpeningStatus } from "@/lib/business/opening-status";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getAvailableServices } from "@/services/scheduling/availability.service";
|
||||
import { listGalleryImages } from "@/services/gallery/gallery.service";
|
||||
import { getLoyaltySummary, type LoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||
import { getBusinessReviews } from "@/services/reviews/reviews.service";
|
||||
import { HomeTabs } from "@/features/home/components/home-tabs";
|
||||
import type { AppointmentStatus } from "@/types/domain";
|
||||
|
||||
export const metadata: Metadata = { title: "Home" };
|
||||
@@ -49,18 +50,50 @@ const STATUS_MAP: Record<string, AppointmentStatus> = {
|
||||
export default async function CustomerHomePage() {
|
||||
const [business, session] = await Promise.all([getPrimaryBusiness(), auth()]);
|
||||
|
||||
const [services, staff, reviews] = await Promise.all([
|
||||
const [services, categories, staff, reviews, heroImages, portfolioImages] = await Promise.all([
|
||||
getAvailableServices(prisma, { businessId: business.id }),
|
||||
prisma.serviceCategory.findMany({ where: { businessId: business.id, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
prisma.staffProfile.findMany({
|
||||
where: { businessId: business.id, status: "ACTIVE", isBookableOnline: true },
|
||||
include: { user: { select: { name: true, image: true } } },
|
||||
}),
|
||||
getBusinessReviews(business.id),
|
||||
listGalleryImages(prisma, { businessId: business.id, placement: GalleryPlacement.HERO, activeOnly: true }),
|
||||
listGalleryImages(prisma, { businessId: business.id, placement: GalleryPlacement.PORTFOLIO, activeOnly: true }),
|
||||
]);
|
||||
|
||||
const openingStatus = computeOpeningStatus(business.hours, business.timezone);
|
||||
const location = business.locations[0];
|
||||
const previewServices = services.slice(0, 4);
|
||||
|
||||
// Hero: owner-managed gallery first; falls back to a single cover/logo
|
||||
// image (still fully functional with zero gallery rows configured —
|
||||
// see docs/phase-9-summary.md, "Home Screen Redesign").
|
||||
const heroGalleryImages =
|
||||
heroImages.length > 0
|
||||
? heroImages.map((i) => ({ id: i.id, url: i.imageUrl, caption: i.caption }))
|
||||
: business.coverImageUrl || business.logoUrl
|
||||
? [{ id: "fallback", url: (business.coverImageUrl ?? business.logoUrl)!, caption: null }]
|
||||
: [];
|
||||
|
||||
const hoursRows = WEEKDAY_ORDER.map((day) => {
|
||||
const dayHours = business.hours.find((h) => h.dayOfWeek === day);
|
||||
const hoursLabel =
|
||||
!dayHours || dayHours.isClosed || dayHours.opensAtMinutes === null || dayHours.closesAtMinutes === null
|
||||
? "Closed"
|
||||
: `${String(Math.floor(dayHours.opensAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.opensAtMinutes % 60).padStart(2, "0")} – ${String(Math.floor(dayHours.closesAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.closesAtMinutes % 60).padStart(2, "0")}`;
|
||||
return { label: WEEKDAY_LABEL[day], hoursLabel };
|
||||
});
|
||||
|
||||
// Keyless Google Maps embed — no API key/SDK needed for a simple pinned
|
||||
// map, unlike the JS Maps SDK. Prefers exact coordinates; falls back to
|
||||
// the street address when they're not set.
|
||||
const mapQuery =
|
||||
location?.latitude && location?.longitude
|
||||
? `${location.latitude},${location.longitude}`
|
||||
: location
|
||||
? `${location.addressLine1}, ${location.city} ${location.postalCode}`
|
||||
: null;
|
||||
const mapEmbedSrc = mapQuery ? `https://www.google.com/maps?q=${encodeURIComponent(mapQuery)}&output=embed` : null;
|
||||
|
||||
type UpcomingAppointment = Prisma.AppointmentGetPayload<{
|
||||
include: { services: { include: { service: true } }; staff: { include: { user: true } } };
|
||||
@@ -107,13 +140,12 @@ export default async function CustomerHomePage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<section className="bg-ink relative h-56 w-full overflow-hidden">
|
||||
{business.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={business.logoUrl} alt="" className="h-full w-full object-cover opacity-80" />
|
||||
) : null}
|
||||
</section>
|
||||
{/* Hero — an owner-managed gallery (see /owner/settings/gallery), matching the content width rather than bleeding edge-to-edge. */}
|
||||
{heroGalleryImages.length > 0 && (
|
||||
<div className="mx-auto max-w-2xl px-6 pt-4">
|
||||
<ImageGallery images={heroGalleryImages} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="mx-auto max-w-2xl bg-white px-6 py-6">
|
||||
{session?.user && (
|
||||
@@ -127,7 +159,7 @@ export default async function CustomerHomePage() {
|
||||
{location.addressLine1}, {location.city} {location.postalCode}
|
||||
</p>
|
||||
)}
|
||||
<div className="mb-4 flex items-center gap-3 text-sm">
|
||||
<div className="mb-6 flex items-center gap-3 text-sm">
|
||||
<div className="text-accent-loyalty flex items-center gap-1">
|
||||
<Star className="h-4 w-4 fill-current" aria-hidden="true" />
|
||||
<span className="text-ink font-bold">{reviews.averageRating.toFixed(1)}</span>
|
||||
@@ -139,9 +171,6 @@ export default async function CustomerHomePage() {
|
||||
{openingStatus.label}
|
||||
</span>
|
||||
</div>
|
||||
{business.description && (
|
||||
<p className="text-muted mb-6 text-sm leading-relaxed">{business.description}</p>
|
||||
)}
|
||||
|
||||
{/* Upcoming appointment */}
|
||||
{upcomingAppointment && (
|
||||
@@ -239,134 +268,26 @@ export default async function CustomerHomePage() {
|
||||
<Link href="/book">Book an Appointment</Link>
|
||||
</Button>
|
||||
|
||||
{/* Services */}
|
||||
<section className="mb-8">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">
|
||||
Popular Services
|
||||
</h2>
|
||||
<Link
|
||||
href="/book"
|
||||
className="text-ink text-[10px] font-bold tracking-widest uppercase underline"
|
||||
>
|
||||
See all
|
||||
</Link>
|
||||
</div>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{previewServices.map((service) => (
|
||||
<ServiceCard
|
||||
key={service.id}
|
||||
service={{
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
durationMinutes: service.durationMinutes,
|
||||
formattedPrice: `£${Number(service.price).toFixed(2)}`,
|
||||
}}
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
<Link href="/book">Book</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Barbers */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">
|
||||
Our Barbers
|
||||
</h2>
|
||||
<div className="flex gap-5 overflow-x-auto pb-1">
|
||||
{staff.map((s) => (
|
||||
<BarberCard
|
||||
key={s.id}
|
||||
staff={{
|
||||
id: s.id,
|
||||
name: s.user.name ?? "Barber",
|
||||
avatarUrl: s.user.image ?? undefined,
|
||||
}}
|
||||
isPreferred={customerProfile?.preferredBarberId === s.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Reviews */}
|
||||
<section className="mb-8">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-muted text-xs font-bold tracking-widest uppercase">Reviews</h2>
|
||||
{reviews.isDemoData && (
|
||||
<span className="text-muted text-[9px] font-bold tracking-widest uppercase">
|
||||
Demo data
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{reviews.reviews.map((review) => (
|
||||
<Card key={review.id} className="p-4">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<p className="text-sm font-bold">{review.authorName}</p>
|
||||
<span className="text-muted text-[10px]">{review.relativeDate}</span>
|
||||
</div>
|
||||
<div className="text-accent-loyalty mb-2 flex gap-0.5">
|
||||
{Array.from({ length: review.rating }, (_, i) => (
|
||||
<Star key={i} className="h-3 w-3 fill-current" aria-hidden="true" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted text-xs">{review.text}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hours */}
|
||||
<section className="mb-4">
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">
|
||||
Opening Hours
|
||||
</h2>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{WEEKDAY_ORDER.map((day) => {
|
||||
const dayHours = business.hours.find((h) => h.dayOfWeek === day);
|
||||
return (
|
||||
<div key={day} className="flex items-center justify-between py-3 text-sm">
|
||||
<span className="text-muted">{WEEKDAY_LABEL[day]}</span>
|
||||
<span className="font-medium">
|
||||
{!dayHours ||
|
||||
dayHours.isClosed ||
|
||||
dayHours.opensAtMinutes === null ||
|
||||
dayHours.closesAtMinutes === null
|
||||
? "Closed"
|
||||
: `${String(Math.floor(dayHours.opensAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.opensAtMinutes % 60).padStart(2, "0")} – ${String(Math.floor(dayHours.closesAtMinutes / 60)).padStart(2, "0")}:${String(dayHours.closesAtMinutes % 60).padStart(2, "0")}`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Contact */}
|
||||
<section>
|
||||
<h2 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Contact</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{business.phone && (
|
||||
<a
|
||||
href={`tel:${business.phone}`}
|
||||
className="border-line rounded-xl border bg-white py-4 text-center text-sm font-bold"
|
||||
>
|
||||
Call
|
||||
</a>
|
||||
)}
|
||||
{business.email && (
|
||||
<a
|
||||
href={`mailto:${business.email}`}
|
||||
className="border-line rounded-xl border bg-white py-4 text-center text-sm font-bold"
|
||||
>
|
||||
Email
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<HomeTabs
|
||||
services={services}
|
||||
categories={categories.map((c) => ({ id: c.id, name: c.name }))}
|
||||
portfolioImages={portfolioImages}
|
||||
reviews={reviews}
|
||||
description={business.description}
|
||||
phone={business.phone}
|
||||
email={business.email}
|
||||
website={business.website}
|
||||
instagramUrl={business.instagramUrl}
|
||||
facebookUrl={business.facebookUrl}
|
||||
hoursRows={hoursRows}
|
||||
staff={staff.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.user.name ?? "Barber",
|
||||
avatarUrl: s.user.image ?? undefined,
|
||||
isPreferred: customerProfile?.preferredBarberId === s.id,
|
||||
}))}
|
||||
mapEmbedSrc={mapEmbedSrc}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,8 @@ import { getUnreadNotificationCount } from "@/services/notifications/notificatio
|
||||
const NAV_ITEMS: BottomNavItem[] = [
|
||||
{ href: "/home", label: "Home", icon: "house" },
|
||||
{ href: "/appointments", label: "Bookings", icon: "calendar-check" },
|
||||
{ href: "/settings", label: "Settings", icon: "user" },
|
||||
{ href: "/loyalty", label: "Loyalty", icon: "gift" },
|
||||
{ href: "/settings", label: "Profile", icon: "user" },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,6 +78,9 @@ export default async function LoyaltyPage() {
|
||||
stampsEarned={loyalty.currentStamps}
|
||||
rewardLabel={loyalty.rewardLabel ?? "Reward"}
|
||||
termsLine={loyalty.availableReward ? undefined : `${loyalty.stampsRequired - loyalty.currentStamps} more to your reward`}
|
||||
cardBackgroundColor={loyalty.cardBackgroundColor}
|
||||
cardBackgroundImageUrl={loyalty.cardBackgroundImageUrl}
|
||||
stampIcon={loyalty.stampIcon}
|
||||
/>
|
||||
|
||||
{loyalty.availableReward ? (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getLoyaltySummary } from "@/services/loyalty/loyalty.service";
|
||||
import { getAppointmentById } from "@/services/scheduling/appointment.service";
|
||||
import { assertAppointmentInBusiness, StaffPermissionDeniedError } from "@/features/staff/authorization";
|
||||
@@ -45,15 +45,18 @@ export default async function OwnerAppointmentDetailsPage({
|
||||
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
|
||||
if (!canView) redirect("/staff");
|
||||
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const appointment = await getAppointmentById(prisma, id);
|
||||
if (!appointment) notFound();
|
||||
|
||||
try {
|
||||
assertAppointmentInBusiness(appointment, { id: owner.id, businessId: owner.businessId });
|
||||
// `id` is unused by assertAppointmentInBusiness (only checks businessId)
|
||||
// — see src/features/staff/authorization.ts. context.userId stands in
|
||||
// for it since a Super Administrator has no StaffProfile id to offer.
|
||||
assertAppointmentInBusiness(appointment, { id: context.userId, businessId: context.businessId });
|
||||
} catch (error) {
|
||||
if (error instanceof StaffPermissionDeniedError) notFound();
|
||||
throw error;
|
||||
@@ -70,13 +73,13 @@ export default async function OwnerAppointmentDetailsPage({
|
||||
where: { appointmentId: appointment.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: owner.businessId }),
|
||||
getLoyaltySummary(prisma, { customerId: appointment.customerId, businessId: context.businessId }),
|
||||
appointment.cancelledByUserId
|
||||
? prisma.user.findUnique({ where: { id: appointment.cancelledByUserId }, select: { name: true } })
|
||||
: null,
|
||||
]);
|
||||
|
||||
const tz = owner.business.timezone;
|
||||
const tz = context.businessTimezone;
|
||||
const totalDuration = appointment.services.reduce((sum, s) => sum + s.durationAtBookingMinutes, 0);
|
||||
const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
|
||||
const discount = appointment.discountAmount ? Number(appointment.discountAmount) : 0;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { AppointmentCard } from "@/components/ui/appointment-card";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import {
|
||||
calendarDateToUtcMidnight,
|
||||
minutesFromMidnight,
|
||||
@@ -61,19 +61,19 @@ export default async function OwnerCalendarPage({
|
||||
const canView = await hasPermission(session.user.id, Permission.VIEW_ALL_STAFF_APPOINTMENTS);
|
||||
if (!canView) redirect("/staff");
|
||||
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const { date: dateParam, view: viewParam, staffId: staffIdParam } = await searchParams;
|
||||
const today = todayIn(owner.business.timezone);
|
||||
const today = todayIn(context.businessTimezone);
|
||||
const date: CalendarDate = dateParam ?? today;
|
||||
const view = viewParam === "agenda" ? "agenda" : "day";
|
||||
const weekday = weekdayOf(date);
|
||||
const dateUtcMidnight = calendarDateToUtcMidnight(date);
|
||||
const businessTimezone = owner.business.timezone;
|
||||
const businessTimezone = context.businessTimezone;
|
||||
|
||||
const allStaff = await prisma.staffProfile.findMany({
|
||||
where: { businessId: owner.businessId, status: "ACTIVE" },
|
||||
where: { businessId: context.businessId, status: "ACTIVE" },
|
||||
include: { user: { select: { id: true, name: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
@@ -81,22 +81,22 @@ export default async function OwnerCalendarPage({
|
||||
|
||||
const [appointments, workingHoursAll, breakDaysAll, timeOffAll] = await Promise.all([
|
||||
listAppointments(prisma, {
|
||||
businessId: owner.businessId,
|
||||
businessId: context.businessId,
|
||||
staffId: selectedStaff?.id,
|
||||
fromDate: date,
|
||||
toDate: date,
|
||||
businessTimezone,
|
||||
}),
|
||||
prisma.workingHours.findMany({
|
||||
where: { staff: { businessId: owner.businessId }, dayOfWeek: weekday },
|
||||
where: { staff: { businessId: context.businessId }, dayOfWeek: weekday },
|
||||
}),
|
||||
prisma.breakDay.findMany({
|
||||
where: { dayOfWeek: weekday, break: { staff: { businessId: owner.businessId } } },
|
||||
where: { dayOfWeek: weekday, break: { staff: { businessId: context.businessId } } },
|
||||
include: { break: true },
|
||||
}),
|
||||
prisma.timeOff.findMany({
|
||||
where: {
|
||||
staff: { businessId: owner.businessId },
|
||||
staff: { businessId: context.businessId },
|
||||
status: "APPROVED",
|
||||
startDate: { lte: dateUtcMidnight },
|
||||
endDate: { gte: dateUtcMidnight },
|
||||
@@ -105,7 +105,7 @@ export default async function OwnerCalendarPage({
|
||||
]);
|
||||
|
||||
const rewardFlags = await getAvailableRewardFlags(prisma, {
|
||||
businessId: owner.businessId,
|
||||
businessId: context.businessId,
|
||||
customerIds: [...new Set(appointments.map((a) => a.customerId))],
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { listInAppNotifications } from "@/services/notifications/notification.service";
|
||||
import { NotificationList } from "@/features/notifications/components/notification-list";
|
||||
|
||||
@@ -11,10 +11,10 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
/** Access to this route is already gated by (owner)/layout.tsx's MANAGE_STAFF check — this page only fetches this owner's own notifications, same as the staff/customer variants. */
|
||||
export default async function OwnerNotificationsPage() {
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/home");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/home");
|
||||
|
||||
const notifications = await listInAppNotifications(prisma, { userId: staff.userId });
|
||||
const notifications = await listInAppNotifications(prisma, { userId: context.userId });
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Card } from "@/components/ui/card";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { todayIn } from "@/lib/time/business-time";
|
||||
import { getDashboardSummary } from "@/services/reporting/owner-reporting.service";
|
||||
|
||||
@@ -44,13 +44,13 @@ export default async function OwnerDashboardPage() {
|
||||
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
|
||||
if (!canView) redirect("/staff");
|
||||
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/staff");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const today = todayIn(staff.business.timezone);
|
||||
const today = todayIn(context.businessTimezone);
|
||||
const summary = await getDashboardSummary(prisma, {
|
||||
businessId: staff.businessId,
|
||||
businessTimezone: staff.business.timezone,
|
||||
businessId: context.businessId,
|
||||
businessTimezone: context.businessTimezone,
|
||||
date: today,
|
||||
});
|
||||
|
||||
@@ -58,7 +58,7 @@ export default async function OwnerDashboardPage() {
|
||||
<div className="space-y-6 px-6 pt-12 pb-12">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted text-sm">{staff.business.name} — today</p>
|
||||
<p className="text-muted text-sm">{context.businessName} — today</p>
|
||||
</div>
|
||||
|
||||
{summary.warnings.length > 0 && (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Card } from "@/components/ui/card";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { addDays, endOfBusinessDay, startOfBusinessDay, todayIn } from "@/lib/time/business-time";
|
||||
import type { CalendarDate } from "@/lib/time/business-time";
|
||||
import {
|
||||
@@ -48,10 +48,10 @@ export default async function OwnerReportsPage({
|
||||
const canView = await hasPermission(session.user.id, Permission.VIEW_REPORTING);
|
||||
if (!canView) redirect("/staff");
|
||||
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/staff");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const tz = staff.business.timezone;
|
||||
const tz = context.businessTimezone;
|
||||
const today = todayIn(tz);
|
||||
const { from: fromParam, to: toParam } = await searchParams;
|
||||
const toDate: CalendarDate = toParam ?? today;
|
||||
@@ -61,12 +61,12 @@ export default async function OwnerReportsPage({
|
||||
|
||||
const [appointmentReport, serviceReport, staffReport, customerReport, loyaltyReport, notificationReport] =
|
||||
await Promise.all([
|
||||
getAppointmentReport(prisma, { businessId: staff.businessId, range }),
|
||||
getServiceReport(prisma, { businessId: staff.businessId, range }),
|
||||
getStaffReport(prisma, { businessId: staff.businessId, range }),
|
||||
getCustomerReport(prisma, { businessId: staff.businessId, range }),
|
||||
getLoyaltyReport(prisma, { businessId: staff.businessId, range }),
|
||||
getNotificationReport(prisma, { businessId: staff.businessId, range }),
|
||||
getAppointmentReport(prisma, { businessId: context.businessId, range }),
|
||||
getServiceReport(prisma, { businessId: context.businessId, range }),
|
||||
getStaffReport(prisma, { businessId: context.businessId, range }),
|
||||
getCustomerReport(prisma, { businessId: context.businessId, range }),
|
||||
getLoyaltyReport(prisma, { businessId: context.businessId, range }),
|
||||
getNotificationReport(prisma, { businessId: context.businessId, range }),
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { listCategories } from "@/services/catalogue/service-catalogue.service";
|
||||
import { CatalogueManager } from "@/features/owner/components/catalogue-manager";
|
||||
|
||||
@@ -18,10 +18,10 @@ export default async function OwnerServicesPage() {
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_SERVICES);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const categories = await listCategories(prisma, owner.businessId);
|
||||
const categories = await listCategories(prisma, context.businessId);
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-12 pb-12 lg:max-w-3xl">
|
||||
@@ -36,6 +36,7 @@ export default async function OwnerServicesPage() {
|
||||
categoryId: s.categoryId,
|
||||
name: s.name,
|
||||
description: s.description ?? "",
|
||||
imageUrl: s.imageUrl,
|
||||
durationMinutes: s.durationMinutes,
|
||||
price: s.price.toString(),
|
||||
bufferBeforeMinutes: s.bufferBeforeMinutes,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getBookingRule } from "@/services/scheduling/booking-rule.service";
|
||||
import { BookingRulesForm } from "@/features/owner/components/booking-rules-form";
|
||||
|
||||
@@ -19,33 +19,30 @@ export default async function OwnerBookingRulesPage() {
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BOOKING_RULES);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const rule = await getBookingRule(prisma, owner.businessId);
|
||||
const rule = await getBookingRule(prisma, context.businessId);
|
||||
if (!rule) redirect("/owner");
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Booking Rules" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
|
||||
<BookingRulesForm
|
||||
initial={{
|
||||
minNoticeMinutes: rule.minNoticeMinutes,
|
||||
maxAdvanceDays: rule.maxAdvanceDays,
|
||||
cancellationWindowHours: rule.cancellationWindowHours,
|
||||
rescheduleWindowHours: rule.rescheduleWindowHours,
|
||||
slotIntervalMinutes: rule.slotIntervalMinutes,
|
||||
bufferBeforeMinutes: rule.bufferBeforeMinutes,
|
||||
bufferAfterMinutes: rule.bufferAfterMinutes,
|
||||
maxAppointmentsPerDay: rule.maxAppointmentsPerDay,
|
||||
maxAppointmentsPerStaffPerDay: rule.maxAppointmentsPerStaffPerDay,
|
||||
customerCanChooseStaff: rule.customerCanChooseStaff,
|
||||
allowGuestBooking: rule.allowGuestBooking,
|
||||
requireApproval: rule.requireApproval,
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
<SettingsPageShell title="Booking Rules">
|
||||
<BookingRulesForm
|
||||
initial={{
|
||||
minNoticeMinutes: rule.minNoticeMinutes,
|
||||
maxAdvanceDays: rule.maxAdvanceDays,
|
||||
cancellationWindowHours: rule.cancellationWindowHours,
|
||||
rescheduleWindowHours: rule.rescheduleWindowHours,
|
||||
slotIntervalMinutes: rule.slotIntervalMinutes,
|
||||
bufferBeforeMinutes: rule.bufferBeforeMinutes,
|
||||
bufferAfterMinutes: rule.bufferAfterMinutes,
|
||||
maxAppointmentsPerDay: rule.maxAppointmentsPerDay,
|
||||
maxAppointmentsPerStaffPerDay: rule.maxAppointmentsPerStaffPerDay,
|
||||
customerCanChooseStaff: rule.customerCanChooseStaff,
|
||||
allowGuestBooking: rule.allowGuestBooking,
|
||||
requireApproval: rule.requireApproval,
|
||||
}}
|
||||
/>
|
||||
</SettingsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getBusinessProfile } from "@/services/business/business.service";
|
||||
import { BusinessDetailsForm } from "@/features/owner/components/business-details-form";
|
||||
|
||||
@@ -19,23 +19,28 @@ export default async function OwnerBusinessDetailsPage() {
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/staff");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const business = await getBusinessProfile(prisma, staff.businessId);
|
||||
const business = await getBusinessProfile(prisma, context.businessId);
|
||||
if (!business) redirect("/owner");
|
||||
|
||||
const location = business.locations[0];
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Business Details" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
|
||||
<BusinessDetailsForm
|
||||
<SettingsPageShell title="Business Details">
|
||||
<BusinessDetailsForm
|
||||
initial={{
|
||||
name: business.name,
|
||||
description: business.description ?? "",
|
||||
logoUrl: business.logoUrl ?? "",
|
||||
logoUrl: business.logoUrl,
|
||||
coverImageUrl: business.coverImageUrl,
|
||||
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
|
||||
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
|
||||
faviconUrl: business.faviconUrl,
|
||||
appIconUrl: business.appIconUrl,
|
||||
themeColor: business.themeColor,
|
||||
backgroundColor: business.backgroundColor,
|
||||
phone: business.phone ?? "",
|
||||
email: business.email ?? "",
|
||||
website: business.website ?? "",
|
||||
@@ -54,9 +59,8 @@ export default async function OwnerBusinessDetailsPage() {
|
||||
lateArrivalPolicy: business.settings?.lateArrivalPolicy ?? "",
|
||||
bookingTerms: business.settings?.bookingTerms ?? "",
|
||||
privacyPolicyUrl: business.settings?.privacyPolicyUrl ?? "",
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
</SettingsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { GalleryPlacement } from "@/generated/prisma/enums";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { listGalleryImages } from "@/services/gallery/gallery.service";
|
||||
import { GalleryManager } from "@/features/owner/components/gallery-manager";
|
||||
|
||||
export const metadata: Metadata = { title: "Gallery" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerGalleryPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const [heroImages, portfolioImages] = await Promise.all([
|
||||
listGalleryImages(prisma, { businessId: context.businessId, placement: GalleryPlacement.HERO }),
|
||||
listGalleryImages(prisma, { businessId: context.businessId, placement: GalleryPlacement.PORTFOLIO }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<SettingsPageShell title="Gallery" mainClassName="space-y-8">
|
||||
<GalleryManager
|
||||
placement={GalleryPlacement.HERO}
|
||||
title="Home Hero Gallery"
|
||||
description="Shown at the top of the customer Home page. Falls back to your cover image, then your logo, when empty."
|
||||
showCategory={false}
|
||||
initialImages={heroImages}
|
||||
/>
|
||||
<GalleryManager
|
||||
placement={GalleryPlacement.PORTFOLIO}
|
||||
title="Portfolio"
|
||||
description="Shown in the Portfolio tab on Home. Add a category to each image to power the filter pills there."
|
||||
showCategory
|
||||
initialImages={portfolioImages}
|
||||
/>
|
||||
</SettingsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getOpeningHours } from "@/services/business/business.service";
|
||||
import { OpeningHoursForm } from "@/features/owner/components/opening-hours-form";
|
||||
|
||||
@@ -19,17 +19,14 @@ export default async function OwnerOpeningHoursPage() {
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const staff = await getActingStaff();
|
||||
if (!staff) redirect("/staff");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const hours = await getOpeningHours(prisma, staff.businessId);
|
||||
const hours = await getOpeningHours(prisma, context.businessId);
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Opening Hours" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6">
|
||||
<OpeningHoursForm initialRows={hours} />
|
||||
</main>
|
||||
</div>
|
||||
<SettingsPageShell title="Opening Hours" mainClassName="lg:max-w-2xl">
|
||||
<OpeningHoursForm initialRows={hours} />
|
||||
</SettingsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getProgrammeConfig } from "@/services/loyalty/loyalty.service";
|
||||
import { LoyaltyConfigForm } from "@/features/owner/components/loyalty-config-form";
|
||||
|
||||
@@ -19,14 +19,14 @@ export default async function OwnerLoyaltyConfigPage() {
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_LOYALTY_PROGRAMME);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const [programme, services, staff] = await Promise.all([
|
||||
getProgrammeConfig(prisma, owner.businessId),
|
||||
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
getProgrammeConfig(prisma, context.businessId),
|
||||
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
prisma.staffProfile.findMany({
|
||||
where: { businessId: owner.businessId, status: "ACTIVE" },
|
||||
where: { businessId: context.businessId, status: "ACTIVE" },
|
||||
include: { user: { select: { name: true } } },
|
||||
}),
|
||||
]);
|
||||
@@ -62,17 +62,15 @@ export default async function OwnerLoyaltyConfigPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title="Loyalty Programme" onBack sticky className="bg-stone" />
|
||||
<main className="mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl">
|
||||
<LoyaltyConfigForm
|
||||
initialConfig={initialConfig}
|
||||
initialEligibleServiceIds={programme?.eligibleServices.map((s) => s.serviceId) ?? []}
|
||||
initialEligibleStaffIds={programme?.eligibleStaff.map((s) => s.staffId) ?? []}
|
||||
services={services.map((s) => ({ id: s.id, name: s.name }))}
|
||||
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff" }))}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
<SettingsPageShell title="Loyalty Programme">
|
||||
<LoyaltyConfigForm
|
||||
businessName={context.businessName}
|
||||
initialConfig={initialConfig}
|
||||
initialEligibleServiceIds={programme?.eligibleServices.map((s) => s.serviceId) ?? []}
|
||||
initialEligibleStaffIds={programme?.eligibleStaff.map((s) => s.staffId) ?? []}
|
||||
services={services.map((s) => ({ id: s.id, name: s.name }))}
|
||||
staff={staff.map((s) => ({ id: s.id, name: s.user.name ?? "Staff" }))}
|
||||
/>
|
||||
</SettingsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { Bell, Building2, CalendarClock, ClipboardList, Crown, ScissorsSquare } from "lucide-react";
|
||||
import { Bell, Building2, CalendarClock, ClipboardList, Crown, Images, ScissorsSquare, Star } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { ProfileCard } from "@/components/ui/profile-card";
|
||||
@@ -15,9 +15,11 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
const SETTINGS_LINKS = [
|
||||
{ href: "/owner/settings/business", label: "Business Details", icon: Building2 },
|
||||
{ href: "/owner/settings/gallery", label: "Gallery", icon: Images },
|
||||
{ href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock },
|
||||
{ href: "/owner/settings/booking-rules", label: "Booking Rules", icon: ClipboardList },
|
||||
{ href: "/owner/settings/loyalty", label: "Loyalty Programme", icon: Crown },
|
||||
{ href: "/owner/settings/reviews", label: "Reviews", icon: Star },
|
||||
{ href: "/staff/settings/notifications", label: "Notifications", icon: Bell },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { SettingsPageShell } from "@/components/settings/settings-page-shell";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getBusinessProfile } from "@/services/business/business.service";
|
||||
import { listReviewsForOwner } from "@/services/reviews/review-management.service";
|
||||
import { ReviewsManager } from "@/features/owner/components/reviews-manager";
|
||||
|
||||
export const metadata: Metadata = { title: "Reviews" };
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OwnerReviewsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/sign-in");
|
||||
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_REVIEWS);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const [business, reviews] = await Promise.all([
|
||||
getBusinessProfile(prisma, context.businessId),
|
||||
listReviewsForOwner(prisma, context.businessId),
|
||||
]);
|
||||
if (!business) redirect("/owner");
|
||||
|
||||
return (
|
||||
<SettingsPageShell title="Reviews">
|
||||
<ReviewsManager
|
||||
initialGooglePlaceId={business.googlePlaceId ?? ""}
|
||||
initialReviewsMinRating={business.settings?.reviewsMinRating ?? null}
|
||||
initialReviewsExcludeRatingOnly={business.settings?.reviewsExcludeRatingOnly ?? false}
|
||||
googleRating={business.googleRating ? Number(business.googleRating) : null}
|
||||
googleRatingCount={business.googleRatingCount}
|
||||
reviews={reviews.map((r) => ({
|
||||
id: r.id,
|
||||
authorName: r.authorName,
|
||||
rating: r.rating,
|
||||
text: r.text,
|
||||
relativeTimeDescription: r.relativeTimeDescription,
|
||||
isHidden: r.isHidden,
|
||||
}))}
|
||||
/>
|
||||
</SettingsPageShell>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { listBreaks } from "@/services/staff/staff.service";
|
||||
import { BreaksList } from "@/features/staff/components/breaks-list";
|
||||
import { createOwnerBreakActionSet } from "@/features/owner/actions/break-action-set";
|
||||
@@ -20,12 +20,12 @@ export default async function OwnerStaffBreaksPage({ params }: { params: Promise
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const target = await prisma.staffProfile.findFirst({
|
||||
where: { id, businessId: owner.businessId },
|
||||
where: { id, businessId: context.businessId },
|
||||
include: { user: { select: { name: true } } },
|
||||
});
|
||||
if (!target) notFound();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission, Weekday } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getWorkingHours } from "@/services/staff/staff.service";
|
||||
import { WorkingHoursForm } from "@/features/staff/components/working-hours-form";
|
||||
import { updateStaffWorkingHoursAction } from "@/features/owner/actions/staff";
|
||||
@@ -36,19 +36,19 @@ export default async function OwnerStaffWorkingHoursPage({ params }: { params: P
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const target = await prisma.staffProfile.findFirst({
|
||||
where: { id, businessId: owner.businessId },
|
||||
where: { id, businessId: context.businessId },
|
||||
include: { user: { select: { name: true } } },
|
||||
});
|
||||
if (!target) notFound();
|
||||
|
||||
const [workingHours, businessHours] = await Promise.all([
|
||||
getWorkingHours(prisma, id),
|
||||
prisma.businessHours.findMany({ where: { businessId: owner.businessId } }),
|
||||
prisma.businessHours.findMany({ where: { businessId: context.businessId } }),
|
||||
]);
|
||||
|
||||
const businessHoursLabel = Object.fromEntries(
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Card } from "@/components/ui/card";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { getStaffDetailById, listRolesForBusiness } from "@/services/staff/staff.service";
|
||||
import { StaffDetailPanel } from "@/features/owner/components/staff-detail-panel";
|
||||
|
||||
@@ -22,14 +22,14 @@ export default async function OwnerStaffDetailPage({ params }: { params: Promise
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const [target, roles, allServices, calendarConnection] = await Promise.all([
|
||||
getStaffDetailById(prisma, { staffId: id, businessId: owner.businessId }),
|
||||
listRolesForBusiness(prisma, owner.businessId),
|
||||
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
getStaffDetailById(prisma, { staffId: id, businessId: context.businessId }),
|
||||
listRolesForBusiness(prisma, context.businessId),
|
||||
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
// Status only — never tokens (see the brief's "Owners must not see
|
||||
// OAuth tokens, refresh tokens, or unrelated private calendar
|
||||
// events"). Owners can see whether a barber has connected, not
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { listTimeOff } from "@/services/staff/staff.service";
|
||||
import { TimeOffList } from "@/features/staff/components/time-off-list";
|
||||
import { createOwnerTimeOffActionSet } from "@/features/owner/actions/time-off-action-set";
|
||||
@@ -24,12 +24,12 @@ export default async function OwnerStaffTimeOffPage({ params }: { params: Promis
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const { id } = await params;
|
||||
const target = await prisma.staffProfile.findFirst({
|
||||
where: { id, businessId: owner.businessId },
|
||||
where: { id, businessId: context.businessId },
|
||||
include: { user: { select: { name: true } } },
|
||||
});
|
||||
if (!target) notFound();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { listRolesForBusiness } from "@/services/staff/staff.service";
|
||||
import { AddStaffForm } from "@/features/owner/components/add-staff-form";
|
||||
|
||||
@@ -19,12 +19,12 @@ export default async function OwnerAddStaffPage() {
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const [roles, services] = await Promise.all([
|
||||
listRolesForBusiness(prisma, owner.businessId),
|
||||
prisma.service.findMany({ where: { businessId: owner.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
listRolesForBusiness(prisma, context.businessId),
|
||||
prisma.service.findMany({ where: { businessId: context.businessId, isActive: true }, orderBy: { displayOrder: "asc" } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { listStaffForBusiness } from "@/services/staff/staff.service";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
@@ -24,11 +24,11 @@ export default async function OwnerTeamPage({
|
||||
const canManage = await hasPermission(session.user.id, Permission.MANAGE_STAFF);
|
||||
if (!canManage) redirect("/staff");
|
||||
|
||||
const owner = await getActingStaff();
|
||||
if (!owner) redirect("/staff");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) redirect("/staff");
|
||||
|
||||
const { q = "" } = await searchParams;
|
||||
const staff = await listStaffForBusiness(prisma, { businessId: owner.businessId, q, includeInactive: true });
|
||||
const staff = await listStaffForBusiness(prisma, { businessId: context.businessId, q, includeInactive: true });
|
||||
|
||||
return (
|
||||
<div className="px-6 pt-12 pb-12 lg:max-w-4xl">
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { isInstalledForRequest } from "@/lib/installation/installation-state";
|
||||
import { secretsMatch } from "@/lib/security/secrets-match";
|
||||
import { syncGoogleReviewsForAllBusinesses } from "@/services/reviews/review-sync.service";
|
||||
|
||||
/**
|
||||
* External trigger for the Google Reviews sync — mirrors
|
||||
* /api/cron/process-notifications exactly (same CRON_SECRET bearer-token
|
||||
* auth, same 503-if-unconfigured / 401-if-wrong / skip-if-not-installed
|
||||
* shape). Recommended frequency: every 24 hours — reviews don't change
|
||||
* fast enough to justify Google Places API calls any more often than
|
||||
* that, and the customer app never calls Google directly regardless (see
|
||||
* src/services/reviews/reviews.service.ts).
|
||||
*
|
||||
* Safe to run concurrently with itself: syncGoogleReviewsForBusiness's
|
||||
* Review upsert is keyed on (businessId, externalId), so an overlapping
|
||||
* call just re-writes the same rows, never duplicates them.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const secret = process.env.CRON_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json({ error: "CRON_SECRET is not configured on this deployment." }, { status: 503 });
|
||||
}
|
||||
|
||||
const provided = request.headers.get("authorization");
|
||||
if (!provided || !secretsMatch(provided, `Bearer ${secret}`)) {
|
||||
return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!(await isInstalledForRequest())) {
|
||||
return NextResponse.json({ skipped: "not_installed" });
|
||||
}
|
||||
|
||||
const results = await syncGoogleReviewsForAllBusinesses(prisma);
|
||||
return NextResponse.json({ results });
|
||||
}
|
||||
+60
-37
@@ -33,50 +33,73 @@ export default async function WelcomePage() {
|
||||
const [branding, business] = await Promise.all([getBranding(), getPrimaryBusiness()]);
|
||||
const guestBookingEnabled = business.bookingRule?.allowGuestBooking ?? false;
|
||||
|
||||
return (
|
||||
<div id="main-content" className="flex min-h-screen flex-col justify-end bg-ink px-8 pb-16 text-white">
|
||||
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-ink">
|
||||
{branding.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- external, business-supplied URL; not a build-time-known local asset for next/image.
|
||||
<img src={branding.logoUrl} alt="" className="h-full w-full rounded-2xl object-cover" />
|
||||
) : (
|
||||
<Scissors className="h-7 w-7" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mb-4 text-5xl font-bold tracking-tight">{branding.name}</h1>
|
||||
<p className="mb-12 max-w-xs text-lg text-white/70">
|
||||
Book your next visit in seconds — services, staff and loyalty rewards in one place.
|
||||
</p>
|
||||
const hasBackground = !!(branding.welcomeBackgroundDesktopUrl || branding.welcomeBackgroundMobileUrl);
|
||||
|
||||
<div className="space-y-4">
|
||||
<Button asChild variant="secondary" size="lg" className="w-full bg-white">
|
||||
<Link href="/register">Create Account</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className="w-full border-2 border-white/20 bg-transparent text-white hover:bg-white/10"
|
||||
>
|
||||
<Link href="/sign-in">Sign In</Link>
|
||||
</Button>
|
||||
{guestBookingEnabled && (
|
||||
return (
|
||||
<div id="main-content" className="relative flex min-h-screen flex-col justify-end overflow-hidden bg-ink px-8 pb-16 text-white">
|
||||
{/* Desktop/mobile are independent images (not one image cropped two ways) — see BrandingInfo.welcomeBackgroundDesktopUrl/MobileUrl. Falls back to the plain bg-ink above when neither is set. */}
|
||||
{branding.welcomeBackgroundDesktopUrl && (
|
||||
<div
|
||||
className="absolute inset-0 hidden bg-cover bg-center md:block"
|
||||
style={{ backgroundImage: `url(${branding.welcomeBackgroundDesktopUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{branding.welcomeBackgroundMobileUrl && (
|
||||
<div
|
||||
className="absolute inset-0 block bg-cover bg-center md:hidden"
|
||||
style={{ backgroundImage: `url(${branding.welcomeBackgroundMobileUrl})` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{hasBackground && (
|
||||
<div className="from-ink via-ink/60 absolute inset-0 bg-gradient-to-t to-transparent" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex flex-1 flex-col justify-end">
|
||||
<div className="mb-8 flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-ink">
|
||||
{branding.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- external, business-supplied URL; not a build-time-known local asset for next/image.
|
||||
<img src={branding.logoUrl} alt="" className="h-full w-full rounded-2xl object-cover" />
|
||||
) : (
|
||||
<Scissors className="h-7 w-7" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mb-4 text-5xl font-bold tracking-tight">{branding.name}</h1>
|
||||
<p className="mb-12 max-w-xs text-lg text-white/70">
|
||||
Book your next visit in seconds — services, staff and loyalty rewards in one place.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Button asChild variant="secondary" size="lg" className="w-full bg-white">
|
||||
<Link href="/register">Create Account</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
className="w-full text-white/70 hover:bg-white/10 hover:text-white"
|
||||
className="w-full border-2 border-white/20 bg-transparent text-white hover:bg-white/10"
|
||||
>
|
||||
<Link href="/book">Continue as Guest</Link>
|
||||
<Link href="/sign-in">Sign In</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild variant="ghost" size="lg" className="w-full text-white/70 hover:bg-white/10 hover:text-white">
|
||||
<Link href="/home">Browse Services</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{guestBookingEnabled && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="w-full text-white/70 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
<Link href="/book">Continue as Guest</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild variant="ghost" size="lg" className="w-full text-white/70 hover:bg-white/10 hover:text-white">
|
||||
<Link href="/home">Browse Services</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<WelcomeInstallCard businessName={branding.name} />
|
||||
<div className="mt-6">
|
||||
<WelcomeInstallCard businessName={branding.name} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { CalendarCheck, CalendarDays, ChartLine, House, Scissors, Settings, User, Users } from "lucide-react";
|
||||
import { CalendarCheck, CalendarDays, ChartLine, Gift, House, Scissors, Settings, User, Users } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
@@ -18,6 +18,7 @@ import { cn } from "@/lib/utils/cn";
|
||||
export const NAV_ICON_MAP = {
|
||||
house: House,
|
||||
"calendar-check": CalendarCheck,
|
||||
gift: Gift,
|
||||
user: User,
|
||||
"calendar-days": CalendarDays,
|
||||
scissors: Scissors,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { TopNav } from "@/components/navigation/top-nav";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export interface SettingsPageShellProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
/** Merged onto the default `<main>` classes via cn()/tailwind-merge — pass e.g. "lg:max-w-2xl" to opt out of the default wide desktop layout, or extra utilities like "space-y-8". */
|
||||
mainClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The TopNav + centered container wrapper repeated verbatim across every
|
||||
* owner settings page (Business Details, Gallery, Loyalty, Booking
|
||||
* Rules, Opening Hours, Reviews, ...) — extracted here in Phase 9 once
|
||||
* enough pages shared it to be worth the indirection. See
|
||||
* docs/phase-9-summary.md, "Architecture Review".
|
||||
*/
|
||||
export function SettingsPageShell({ title, children, mainClassName }: SettingsPageShellProps) {
|
||||
return (
|
||||
<div className="pb-12">
|
||||
<TopNav title={title} onBack sticky className="bg-stone" />
|
||||
<main className={cn("mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl", mainClassName)}>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
const DEFAULT_PRESETS = ["#111b15", "#1d4ed8", "#b91c1c", "#15803d", "#a16207", "#374151", "#ffffff"];
|
||||
const HEX_PATTERN = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
export interface ColorPickerProps {
|
||||
label?: string;
|
||||
value: string | null | undefined;
|
||||
onChange: (hex: string) => void;
|
||||
presets?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single reusable colour input — native swatch + hex text field + presets
|
||||
* — shared by theme/background colour (Business Branding) and the
|
||||
* loyalty card designer rather than each screen rolling its own. See
|
||||
* docs/phase-9-summary.md.
|
||||
*/
|
||||
export function ColorPicker({ label, value, onChange, presets = DEFAULT_PRESETS, className }: ColorPickerProps) {
|
||||
const id = useId();
|
||||
const [text, setText] = useState(value ?? "");
|
||||
const swatchColor = value && HEX_PATTERN.test(value) ? value : "#000000";
|
||||
|
||||
function commit(next: string) {
|
||||
setText(next);
|
||||
if (HEX_PATTERN.test(next)) onChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
{label && (
|
||||
<label htmlFor={id} className="text-muted mb-2 ml-1 block text-[10px] font-bold tracking-widest uppercase">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<label
|
||||
className="border-line relative h-11 w-11 shrink-0 overflow-hidden rounded-xl border"
|
||||
style={{ backgroundColor: swatchColor }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={swatchColor}
|
||||
onChange={(e) => commit(e.target.value)}
|
||||
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||
aria-label={label ? `${label} colour swatch` : "Colour swatch"}
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => commit(e.target.value)}
|
||||
placeholder="#111b15"
|
||||
spellCheck={false}
|
||||
className="border-line placeholder:text-muted focus:ring-ink w-full rounded-xl border bg-white px-4 py-3 font-mono text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
{presets.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => commit(preset)}
|
||||
aria-label={`Use ${preset}`}
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-full border-2",
|
||||
swatchColor.toLowerCase() === preset.toLowerCase() ? "border-ink" : "border-transparent",
|
||||
)}
|
||||
style={{ backgroundColor: preset }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Search } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { ICON_CATALOG, findCatalogIcon } from "@/lib/icons/icon-catalog";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export interface IconPickerProps {
|
||||
label?: string;
|
||||
value: string | null | undefined;
|
||||
onChange: (iconName: string) => void;
|
||||
}
|
||||
|
||||
/** Searchable Font Awesome icon picker — replaces free-text icon-name input (e.g. the loyalty stamp icon). See src/lib/icons/icon-catalog.ts. */
|
||||
export function IconPicker({ label, value, onChange }: IconPickerProps) {
|
||||
const labelId = useId();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const selected = findCatalogIcon(value);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return ICON_CATALOG;
|
||||
return ICON_CATALOG.filter((icon) => icon.iconName.toLowerCase().includes(q));
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<span id={labelId} className="text-muted mb-2 ml-1 block text-[10px] font-bold tracking-widest uppercase">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-labelledby={label ? labelId : undefined}
|
||||
className="border-line flex w-full items-center gap-3 rounded-xl border bg-white px-5 py-4 text-left text-sm"
|
||||
>
|
||||
<span className="bg-stone flex h-9 w-9 shrink-0 items-center justify-center rounded-full">
|
||||
<FontAwesomeIcon icon={selected} className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1 capitalize">{selected.iconName.replace(/-/g, " ")}</span>
|
||||
<span className="text-muted text-xs font-bold tracking-widest uppercase">Change</span>
|
||||
</button>
|
||||
|
||||
<Modal open={open} onClose={() => setOpen(false)} title="Choose an icon">
|
||||
<div className="relative mb-4">
|
||||
<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
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search icons"
|
||||
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-3 pr-4 pl-11 text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
</div>
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted py-6 text-center text-sm">No icons match “{query}”.</p>
|
||||
) : (
|
||||
<div className="grid max-h-80 grid-cols-5 gap-2 overflow-y-auto">
|
||||
{filtered.map((icon) => (
|
||||
<button
|
||||
key={icon.iconName}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(icon.iconName);
|
||||
setOpen(false);
|
||||
}}
|
||||
aria-label={icon.iconName}
|
||||
aria-pressed={icon.iconName === selected.iconName}
|
||||
className={cn(
|
||||
"flex aspect-square flex-col items-center justify-center rounded-xl border",
|
||||
icon.iconName === selected.iconName ? "border-ink bg-stone" : "border-line hover:bg-stone",
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export interface ImageGalleryProps {
|
||||
images: { id: string; url: string; caption?: string | null }[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Home page hero — a real gallery with thumbnails (Phase 9), replacing
|
||||
* the old single full-bleed logo banner. Matches the content width rather
|
||||
* than bleeding edge-to-edge, per the brief. Plain click-to-swap + arrow
|
||||
* buttons; no swipe-gesture library, since the thumbnail strip already
|
||||
* covers "jump to any image" and arrows cover "next/previous" — a
|
||||
* touch-drag carousel would be a reasonable future upgrade, not required
|
||||
* for this to be a real gallery.
|
||||
*/
|
||||
export function ImageGallery({ images, className }: ImageGalleryProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
if (images.length === 0) return null;
|
||||
|
||||
const active = images[Math.min(activeIndex, images.length - 1)]!;
|
||||
const goTo = (index: number) => setActiveIndex((index + images.length) % images.length);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<div className="bg-stone relative aspect-[16/9] w-full overflow-hidden rounded-jumbo">
|
||||
{/* 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" />
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goTo(activeIndex - 1)}
|
||||
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"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goTo(activeIndex + 1)}
|
||||
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"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{images.length > 1 && (
|
||||
<div className="mt-2 flex gap-2 overflow-x-auto pb-1">
|
||||
{images.map((image, index) => (
|
||||
<button
|
||||
key={image.id}
|
||||
type="button"
|
||||
onClick={() => setActiveIndex(index)}
|
||||
aria-label={`Show image ${index + 1}`}
|
||||
aria-current={index === activeIndex}
|
||||
className={cn(
|
||||
"h-14 w-20 shrink-0 overflow-hidden rounded-lg border-2",
|
||||
index === activeIndex ? "border-ink" : "border-transparent opacity-70",
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
|
||||
<img src={image.url} alt="" className="h-full w-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Scissors } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { findCatalogIcon } from "@/lib/icons/icon-catalog";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
export interface LoyaltyCardProps {
|
||||
@@ -11,15 +11,23 @@ export interface LoyaltyCardProps {
|
||||
stampsEarned: number;
|
||||
rewardLabel: string;
|
||||
termsLine?: string;
|
||||
/** Owner-configured card appearance (LoyaltyProgramme) — see the loyalty settings designer. All optional; sensible defaults apply when unset. */
|
||||
cardBackgroundColor?: string | null;
|
||||
cardBackgroundImageUrl?: string | null;
|
||||
stampIcon?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ports the 3D flip loyalty card from templates/11 — the best-executed
|
||||
* visual in the whole template set (see docs/implementation-plan.md §2) —
|
||||
* but fixes the gap flagged in §8: the original had no actual flip
|
||||
* trigger wired up. Here the card is a real <button> with
|
||||
* `aria-pressed`, and the flip transition is skipped for users who
|
||||
* prefer reduced motion.
|
||||
* Static, non-flipping card — see docs/phase-9-summary.md ("Loyalty Card
|
||||
* Improvements"). Previously a 3D flip whose back face just repeated the
|
||||
* business name in a generic panel; the brief asked to drop that and only
|
||||
* ever show the stamped side, restyled to feel like a real plastic
|
||||
* loyalty card (diagonal shine + soft depth via layered CSS
|
||||
* gradients/shadows only — no extra image asset, so this stays cheap to
|
||||
* render). Also, previously, cardBackgroundColor/cardBackgroundImageUrl/
|
||||
* stampIcon were stored on LoyaltyProgramme but never actually reached
|
||||
* this component — every business rendered the same hardcoded card
|
||||
* regardless of configuration. Fixed here.
|
||||
*/
|
||||
export function LoyaltyCard({
|
||||
businessName,
|
||||
@@ -28,80 +36,74 @@ export function LoyaltyCard({
|
||||
stampsEarned,
|
||||
rewardLabel,
|
||||
termsLine,
|
||||
cardBackgroundColor,
|
||||
cardBackgroundImageUrl,
|
||||
stampIcon,
|
||||
}: LoyaltyCardProps) {
|
||||
const [flipped, setFlipped] = useState(false);
|
||||
const icon = findCatalogIcon(stampIcon);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFlipped((v) => !v)}
|
||||
aria-pressed={flipped}
|
||||
aria-label={flipped ? "Show stamp side" : "Show card back"}
|
||||
className="w-full [perspective:1000px]"
|
||||
style={{ aspectRatio: "1.65 / 1" }}
|
||||
<div
|
||||
className="ring-accent-loyalty relative w-full overflow-hidden rounded-2xl shadow-[0_30px_60px_rgba(0,0,0,0.45)] ring-[3px]"
|
||||
style={{ aspectRatio: "1.65 / 1", backgroundColor: cardBackgroundColor || "#141414" }}
|
||||
role="img"
|
||||
aria-label={`${businessName} loyalty card, ${stampsEarned} of ${stampsRequired} stamps`}
|
||||
>
|
||||
{cardBackgroundImageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide
|
||||
<img src={cardBackgroundImageUrl} alt="" className="absolute inset-0 h-full w-full object-cover" />
|
||||
)}
|
||||
{/* Dark scrim so stamps/text stay legible over an arbitrary background image. */}
|
||||
{cardBackgroundImageUrl && <div className="absolute inset-0 bg-black/35" aria-hidden="true" />}
|
||||
{/* Premium plastic-card shine — a soft diagonal highlight, CSS-only. */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-full w-full rounded-2xl shadow-[0_30px_60px_rgba(0,0,0,0.45)] transition-transform duration-700 [transform-style:preserve-3d] motion-reduce:transition-none",
|
||||
"ring-[3px] ring-accent-loyalty",
|
||||
)}
|
||||
style={{ transform: flipped ? "rotateY(180deg)" : "rotateY(0deg)" }}
|
||||
>
|
||||
{/* Front — stamps */}
|
||||
<div className="absolute inset-0 flex flex-col justify-between overflow-hidden rounded-2xl bg-[#141414] p-4 [backface-visibility:hidden]">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-black italic tracking-tight text-white">Loyalty Card</h2>
|
||||
<span className="rounded border border-accent-loyalty/30 px-2 py-0.5 text-[8px] font-bold uppercase tracking-widest text-accent-loyalty">
|
||||
Member #{memberNumber}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{Array.from({ length: stampsRequired }, (_, i) => {
|
||||
const earned = i < stampsEarned;
|
||||
// Only the final slot gets reward styling, and only once it's
|
||||
// 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 (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex aspect-square items-center justify-center rounded-full",
|
||||
isRewardSlot
|
||||
? "scale-[1.08] bg-accent-loyalty shadow-lg"
|
||||
: earned
|
||||
? "bg-white shadow-inner"
|
||||
: "border-2 border-dashed border-white/20 bg-[#2a2a2a]",
|
||||
)}
|
||||
>
|
||||
{isRewardSlot ? (
|
||||
<span className="text-center text-[7px] font-black leading-tight text-ink">
|
||||
{rewardLabel}
|
||||
</span>
|
||||
) : earned ? (
|
||||
<Scissors className="h-3.5 w-3.5 text-ink" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{termsLine && (
|
||||
<p className="text-center text-[7px] font-bold uppercase tracking-widest text-white/40">
|
||||
{termsLine}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(115deg, rgba(255,255,255,0.16) 0%, rgba(255,255,255,0) 30%, rgba(255,255,255,0) 70%, rgba(255,255,255,0.08) 100%)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Back — brand */}
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-2xl bg-ink [backface-visibility:hidden] [transform:rotateY(180deg)]">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-white text-3xl text-ink">
|
||||
<Scissors className="h-7 w-7" aria-hidden="true" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-black uppercase tracking-tighter text-white">{businessName}</h3>
|
||||
</div>
|
||||
<div className="relative flex h-full flex-col justify-between p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
Member #{memberNumber}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{Array.from({ length: stampsRequired }, (_, i) => {
|
||||
const earned = i < stampsEarned;
|
||||
// Only the final slot gets reward styling, and only once it's
|
||||
// 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 (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex aspect-square items-center justify-center rounded-full",
|
||||
isRewardSlot
|
||||
? "bg-accent-loyalty scale-[1.08] shadow-lg"
|
||||
: earned
|
||||
? "bg-white shadow-inner"
|
||||
: "border-2 border-dashed border-white/20 bg-white/10",
|
||||
)}
|
||||
>
|
||||
{isRewardSlot ? (
|
||||
<span className="text-ink text-center text-[7px] leading-tight font-black">{rewardLabel}</span>
|
||||
) : earned ? (
|
||||
<FontAwesomeIcon icon={icon} className="text-ink h-3.5 w-3.5" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{termsLine && (
|
||||
<p className="text-center text-[7px] font-bold tracking-widest text-white/40 uppercase">{termsLine}</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,16 @@ export interface ServiceCardProps {
|
||||
export function ServiceCard({ service, action }: ServiceCardProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 py-4">
|
||||
{service.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide
|
||||
<img src={service.imageUrl} alt="" className="bg-stone h-14 w-14 shrink-0 rounded-xl object-cover" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium">{service.name}</h4>
|
||||
<p className="mt-1 text-xs text-muted">{service.durationMinutes}m</p>
|
||||
{service.description && (
|
||||
<p className="mt-1 text-xs text-muted line-clamp-2">{service.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-bold">{service.formattedPrice}</span>
|
||||
|
||||
@@ -88,9 +88,16 @@ export function ServiceSelector(props: {
|
||||
aria-pressed={selected}
|
||||
className="flex w-full items-center justify-between gap-4 px-5 py-4 text-left"
|
||||
>
|
||||
{service.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide
|
||||
<img src={service.imageUrl} alt="" className="bg-stone h-12 w-12 shrink-0 rounded-xl object-cover" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium">{service.name}</h3>
|
||||
<p className="text-muted mt-1 text-xs">{service.durationMinutes}m</p>
|
||||
{service.description && (
|
||||
<p className="text-muted mt-1 text-xs line-clamp-2">{service.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-bold">£{Number(service.price).toFixed(2)}</span>
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import type { GalleryPlacement } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import {
|
||||
addGalleryImage,
|
||||
deleteGalleryImage,
|
||||
reorderGalleryImages,
|
||||
updateGalleryImage,
|
||||
} from "@/services/gallery/gallery.service";
|
||||
import type { GalleryImageSummary } from "@/services/gallery/gallery.service";
|
||||
|
||||
export interface GalleryActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Gallery/portfolio management is business-content, same as logo/cover — reuses MANAGE_BUSINESS_PROFILE rather than a new permission. */
|
||||
async function requireGalleryManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { businessId: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { businessId: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) return { businessId: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { businessId: context.businessId, error: null } as const;
|
||||
}
|
||||
|
||||
export async function addGalleryImageAction(params: {
|
||||
placement: GalleryPlacement;
|
||||
imageUrl: string;
|
||||
category?: string;
|
||||
}): Promise<{ success: true; image: GalleryImageSummary } | { success: false; error: string }> {
|
||||
const { businessId, error } = await requireGalleryManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const image = await addGalleryImage(prisma, { businessId, ...params });
|
||||
return { success: true, image };
|
||||
}
|
||||
|
||||
export async function updateGalleryImageAction(
|
||||
id: string,
|
||||
input: { category?: string | null; caption?: string | null; isActive?: boolean },
|
||||
): Promise<GalleryActionResult> {
|
||||
const { businessId, error } = await requireGalleryManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await updateGalleryImage(prisma, { id, businessId, ...input });
|
||||
if (!result.success) return { success: false, error: "Image not found." };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteGalleryImageAction(id: string): Promise<GalleryActionResult> {
|
||||
const { businessId, error } = await requireGalleryManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await deleteGalleryImage(prisma, { id, businessId });
|
||||
if (!result.success) return { success: false, error: "Image not found." };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function reorderGalleryImagesAction(placement: GalleryPlacement, orderedIds: string[]): Promise<GalleryActionResult> {
|
||||
const { businessId, error } = await requireGalleryManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
await reorderGalleryImages(prisma, { businessId, placement, orderedIds });
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
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 { Card } from "@/components/ui/card";
|
||||
import { ServiceCard } from "@/components/ui/service-card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import type { AvailableService } from "@/services/scheduling/availability.service";
|
||||
import type { BusinessReviewSummary } from "@/services/reviews/reviews.service";
|
||||
import type { GalleryImageSummary } from "@/services/gallery/gallery.service";
|
||||
import type { StaffSummary } from "@/types/domain";
|
||||
|
||||
export interface HomeTabsProps {
|
||||
services: AvailableService[];
|
||||
categories: { id: string; name: string }[];
|
||||
portfolioImages: GalleryImageSummary[];
|
||||
reviews: BusinessReviewSummary;
|
||||
description: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
website: string | null;
|
||||
instagramUrl: string | null;
|
||||
facebookUrl: string | null;
|
||||
hoursRows: { label: string; hoursLabel: string }[];
|
||||
staff: (StaffSummary & { isPreferred: boolean })[];
|
||||
mapEmbedSrc: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky Services/Portfolio/Reviews/Details tabs below the Home hero —
|
||||
* Phase 9's Home Screen Redesign. All data is pre-fetched server-side by
|
||||
* the Home page and passed in as plain props; tab switching and the
|
||||
* Services search/category filter are pure client-side state over that
|
||||
* already-fetched data, not new network round trips per interaction.
|
||||
*/
|
||||
export function HomeTabs(props: HomeTabsProps) {
|
||||
return (
|
||||
<Tabs defaultValue="services">
|
||||
<TabsList className="bg-stone border-line sticky top-0 z-30 -mx-6 rounded-none border-b px-6 py-3">
|
||||
<TabsTrigger value="services">Services</TabsTrigger>
|
||||
<TabsTrigger value="portfolio">Portfolio</TabsTrigger>
|
||||
<TabsTrigger value="reviews">Reviews</TabsTrigger>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="services">
|
||||
<ServicesTab services={props.services} categories={props.categories} />
|
||||
</TabsContent>
|
||||
<TabsContent value="portfolio">
|
||||
<PortfolioTab images={props.portfolioImages} />
|
||||
</TabsContent>
|
||||
<TabsContent value="reviews">
|
||||
<ReviewsTab reviews={props.reviews} />
|
||||
</TabsContent>
|
||||
<TabsContent value="details">
|
||||
<DetailsTab
|
||||
description={props.description}
|
||||
phone={props.phone}
|
||||
email={props.email}
|
||||
website={props.website}
|
||||
instagramUrl={props.instagramUrl}
|
||||
facebookUrl={props.facebookUrl}
|
||||
hoursRows={props.hoursRows}
|
||||
staff={props.staff}
|
||||
mapEmbedSrc={props.mapEmbedSrc}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function ServicesTab({
|
||||
services,
|
||||
categories,
|
||||
}: {
|
||||
services: AvailableService[];
|
||||
categories: { id: string; name: string }[];
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return services.filter((s) => {
|
||||
if (activeCategoryId && s.categoryId !== activeCategoryId) return false;
|
||||
if (q && !s.name.toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [services, query, activeCategoryId]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const byCategory = new Map<string, AvailableService[]>();
|
||||
for (const service of filtered) {
|
||||
const list = byCategory.get(service.categoryId) ?? [];
|
||||
list.push(service);
|
||||
byCategory.set(service.categoryId, list);
|
||||
}
|
||||
return categories
|
||||
.map((c) => ({ ...c, services: byCategory.get(c.id) ?? [] }))
|
||||
.filter((g) => g.services.length > 0);
|
||||
}, [filtered, categories]);
|
||||
|
||||
const categoriesInUse = useMemo(() => categories.filter((c) => services.some((s) => s.categoryId === c.id)), [categories, services]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<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" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search services"
|
||||
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>
|
||||
|
||||
{categoriesInUse.length > 1 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCategoryId(null)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
activeCategoryId === null ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categoriesInUse.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
onClick={() => setActiveCategoryId(c.id)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
activeCategoryId === c.id ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped.length === 0 ? (
|
||||
<p className="text-muted py-6 text-center text-sm">No services match.</p>
|
||||
) : (
|
||||
grouped.map((group) => (
|
||||
<section key={group.id}>
|
||||
<h3 className="text-muted mb-2 text-xs font-bold tracking-widest uppercase">{group.name}</h3>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{group.services.map((service) => (
|
||||
<ServiceCard
|
||||
key={service.id}
|
||||
service={{
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
description: service.description,
|
||||
imageUrl: service.imageUrl,
|
||||
durationMinutes: service.durationMinutes,
|
||||
formattedPrice: `£${Number(service.price).toFixed(2)}`,
|
||||
}}
|
||||
action={
|
||||
<Button asChild size="sm">
|
||||
<Link href="/book">Book</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PortfolioTab({ images }: { images: GalleryImageSummary[] }) {
|
||||
const categories = useMemo(
|
||||
() => Array.from(new Set(images.map((i) => i.category).filter((c): c is string => !!c))),
|
||||
[images],
|
||||
);
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
|
||||
const filtered = activeCategory ? images.filter((i) => i.category === activeCategory) : images;
|
||||
|
||||
if (images.length === 0) {
|
||||
return <p className="text-muted py-10 text-center text-sm">No portfolio images yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(null)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
activeCategory === null ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(c)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
activeCategory === c ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{filtered.map((image) => (
|
||||
<div key={image.id} className="bg-stone aspect-square overflow-hidden rounded-xl">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
|
||||
<img src={image.imageUrl} alt={image.caption ?? ""} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewsTab({ reviews }: { reviews: BusinessReviewSummary }) {
|
||||
if (reviews.reviews.length === 0) {
|
||||
return <p className="text-muted py-10 text-center text-sm">No reviews yet.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{reviews.isDemoData && (
|
||||
<p className="text-muted text-right text-[9px] font-bold tracking-widest uppercase">Demo data</p>
|
||||
)}
|
||||
{reviews.reviews.map((review) => (
|
||||
<Card key={review.id} className="p-4">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<p className="text-sm font-bold">{review.authorName}</p>
|
||||
<span className="text-muted text-[10px]">{review.relativeDate}</span>
|
||||
</div>
|
||||
<div className="text-accent-loyalty mb-2 flex gap-0.5">
|
||||
{Array.from({ length: review.rating }, (_, i) => (
|
||||
<Star key={i} className="h-3 w-3 fill-current" aria-hidden="true" />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted text-xs">{review.text}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsTab({
|
||||
description,
|
||||
phone,
|
||||
email,
|
||||
website,
|
||||
instagramUrl,
|
||||
facebookUrl,
|
||||
hoursRows,
|
||||
staff,
|
||||
mapEmbedSrc,
|
||||
}: {
|
||||
description: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
website: string | null;
|
||||
instagramUrl: string | null;
|
||||
facebookUrl: string | null;
|
||||
hoursRows: { label: string; hoursLabel: string }[];
|
||||
staff: (StaffSummary & { isPreferred: boolean })[];
|
||||
mapEmbedSrc: string | null;
|
||||
}) {
|
||||
const hasSocial = website || instagramUrl || facebookUrl;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{mapEmbedSrc && (
|
||||
<section>
|
||||
<div className="bg-stone aspect-[16/9] w-full overflow-hidden rounded-jumbo">
|
||||
<iframe
|
||||
src={mapEmbedSrc}
|
||||
title="Business location map"
|
||||
className="h-full w-full border-0"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<section>
|
||||
<h3 className="text-muted mb-2 text-xs font-bold tracking-widest uppercase">About Us</h3>
|
||||
<p className="text-muted text-sm leading-relaxed">{description}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{staff.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Staff</h3>
|
||||
<div className="flex gap-5 overflow-x-auto pb-1">
|
||||
{staff.map((s) => (
|
||||
<BarberCard key={s.id} staff={s} isPreferred={s.isPreferred} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hoursRows.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Opening Hours</h3>
|
||||
<Card className="divide-line divide-y px-5">
|
||||
{hoursRows.map((row) => (
|
||||
<div key={row.label} className="flex items-center justify-between py-3 text-sm">
|
||||
<span className="text-muted">{row.label}</span>
|
||||
<span className="font-medium">{row.hoursLabel}</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(hasSocial || phone || email) && (
|
||||
<section>
|
||||
<h3 className="text-muted mb-3 text-xs font-bold tracking-widest uppercase">Social & Contact</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{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">
|
||||
<Phone className="text-accent h-4 w-4" aria-hidden="true" /> Call
|
||||
</a>
|
||||
)}
|
||||
{email && (
|
||||
<a href={`mailto:${email}`} className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold">
|
||||
<Mail className="text-accent h-4 w-4" aria-hidden="true" /> Email
|
||||
</a>
|
||||
)}
|
||||
{website && (
|
||||
<a
|
||||
href={website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold"
|
||||
>
|
||||
<Globe className="text-accent h-4 w-4" aria-hidden="true" /> Website
|
||||
</a>
|
||||
)}
|
||||
{instagramUrl && (
|
||||
<a
|
||||
href={instagramUrl.startsWith("http") ? instagramUrl : `https://instagram.com/${instagramUrl.replace(/^@/, "")}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold"
|
||||
>
|
||||
<AtSign className="text-accent h-4 w-4" aria-hidden="true" /> Instagram
|
||||
</a>
|
||||
)}
|
||||
{facebookUrl && (
|
||||
<a
|
||||
href={facebookUrl.startsWith("http") ? facebookUrl : `https://${facebookUrl}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="border-line flex items-center justify-center gap-2 rounded-xl border bg-white py-4 text-sm font-bold"
|
||||
>
|
||||
<Share2 className="text-accent h-4 w-4" aria-hidden="true" /> Facebook
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import type { ActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { createMediaAsset, deleteMediaAsset, listMediaAssets } from "@/services/media/media.service";
|
||||
import type { MediaAssetSummary } from "@/services/media/media.service";
|
||||
import { imageSize } from "image-size";
|
||||
import { isAllowedMediaMimeType, MEDIA_MAX_UPLOAD_BYTES, storeMediaFile } from "@/lib/media/media-storage";
|
||||
|
||||
/**
|
||||
* File uploads travel through a Server Action, not a separate REST route
|
||||
* — Next.js Server Actions accept FormData (including File values)
|
||||
* natively, so this stays consistent with every other owner mutation in
|
||||
* the app instead of introducing a one-off API route just for this.
|
||||
*
|
||||
* Uses getActingBusinessContext (not getActingStaff/a raw StaffProfile
|
||||
* lookup) so a Super Administrator — who by design has no StaffProfile,
|
||||
* see docs/phase-9-summary.md — can manage the media library too.
|
||||
*
|
||||
* Deliberately NOT gated on MANAGE_MEDIA_LIBRARY here: any signed-in
|
||||
* staff member needs to add/browse images for fields they're otherwise
|
||||
* allowed to edit (e.g. a barber uploading their own profile photo).
|
||||
* What a URL actually gets SAVED to is still authorized by that specific
|
||||
* field's own Server Action (updateProfileAction, updateBusinessProfileAction,
|
||||
* etc.) exactly as before — this only gates "can add to the library at all".
|
||||
*/
|
||||
async function requireStaffOrSuperAdmin(): Promise<ActingBusinessContext> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("You must be signed in.");
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) throw new Error("You must be signed in as staff.");
|
||||
return context;
|
||||
}
|
||||
|
||||
/** Deleting an image can affect fields other staff/other pages reference — restricted to MANAGE_MEDIA_LIBRARY (Owner/Super Admin), unlike upload/browse above. */
|
||||
async function requireMediaLibraryManager(): Promise<ActingBusinessContext> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("You must be signed in.");
|
||||
if (!(await hasPermission(session.user.id, Permission.MANAGE_MEDIA_LIBRARY))) {
|
||||
throw new Error("You don't have permission to delete media.");
|
||||
}
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) throw new Error("No business context.");
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function listMediaAssetsAction(params: {
|
||||
category?: string;
|
||||
q?: string;
|
||||
}): Promise<{ success: true; assets: MediaAssetSummary[] } | { success: false; error: string }> {
|
||||
try {
|
||||
const context = await requireStaffOrSuperAdmin();
|
||||
const assets = await listMediaAssets(prisma, { businessId: context.businessId, ...params });
|
||||
return { success: true, assets };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : "Couldn't load the media library." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadMediaAssetAction(
|
||||
formData: FormData,
|
||||
): Promise<{ success: true; asset: MediaAssetSummary } | { success: false; error: string }> {
|
||||
try {
|
||||
const context = await requireStaffOrSuperAdmin();
|
||||
|
||||
const file = formData.get("file");
|
||||
if (!(file instanceof File)) return { success: false, error: "No file provided." };
|
||||
if (!isAllowedMediaMimeType(file.type)) {
|
||||
return { success: false, error: "Unsupported image type — use PNG, JPEG, WEBP, or GIF." };
|
||||
}
|
||||
if (file.size > MEDIA_MAX_UPLOAD_BYTES) {
|
||||
return { success: false, error: `File is too large — max ${Math.floor(MEDIA_MAX_UPLOAD_BYTES / 1_000_000)}MB.` };
|
||||
}
|
||||
|
||||
const categoryRaw = formData.get("category");
|
||||
const category = typeof categoryRaw === "string" && categoryRaw.trim() ? categoryRaw.trim() : "other";
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
let width: number | null = null;
|
||||
let height: number | null = null;
|
||||
try {
|
||||
const dimensions = imageSize(buffer);
|
||||
width = dimensions.width ?? null;
|
||||
height = dimensions.height ?? null;
|
||||
} catch {
|
||||
// Best-effort — an unrecognised/corrupt header shouldn't block the upload.
|
||||
}
|
||||
|
||||
const stored = await storeMediaFile({ buffer, mimeType: file.type, category });
|
||||
const asset = await createMediaAsset(prisma, {
|
||||
businessId: context.businessId,
|
||||
url: stored.url,
|
||||
originalFilename: file.name || null,
|
||||
mimeType: file.type,
|
||||
sizeBytes: file.size,
|
||||
width,
|
||||
height,
|
||||
category,
|
||||
uploadedByUserId: context.userId,
|
||||
});
|
||||
|
||||
return { success: true, asset };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : "Upload failed." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMediaAssetAction(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const context = await requireMediaLibraryManager();
|
||||
const result = await deleteMediaAsset(prisma, { id, businessId: context.businessId });
|
||||
if (!result.success) return { success: false, error: "That media item couldn't be found." };
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : "Delete failed." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { Check, Copy, ImageOff, Link as LinkIcon, Search, Trash2, Upload } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { deleteMediaAssetAction, listMediaAssetsAction, uploadMediaAssetAction } from "@/features/media/actions/media";
|
||||
import type { MediaAssetSummary } from "@/services/media/media.service";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export interface MediaManagerModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (url: string) => void;
|
||||
/** Tags new uploads and pre-filters the Library tab — e.g. "branding", "loyalty", "staff", "service", "portfolio", "welcome". */
|
||||
category: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one Media Manager used everywhere an image field exists — see
|
||||
* docs/phase-9-summary.md. Upload / Library / External URL, all in one
|
||||
* dialog, backed by src/features/media/actions/media.ts (Server Actions,
|
||||
* not a REST route — see that file's header for why).
|
||||
*/
|
||||
export function MediaManagerModal({ open, onClose, onSelect, category }: MediaManagerModalProps) {
|
||||
const [tab, setTab] = useState("library");
|
||||
const [assets, setAssets] = useState<MediaAssetSummary[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [scope, setScope] = useState<"category" | "all">("category");
|
||||
const [selectedAsset, setSelectedAsset] = useState<MediaAssetSummary | null>(null);
|
||||
const [externalUrl, setExternalUrl] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
startTransition(async () => {
|
||||
const result = await listMediaAssetsAction({ category: scope === "category" ? category : undefined, q: query || undefined });
|
||||
if (result.success) {
|
||||
setAssets(result.assets);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
});
|
||||
}, [open, scope, query, category, startTransition]);
|
||||
|
||||
function handleUpload(file: File) {
|
||||
setError(null);
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
formData.set("category", category);
|
||||
startTransition(async () => {
|
||||
const result = await uploadMediaAssetAction(formData);
|
||||
if (!result.success) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
onSelect(result.asset.url);
|
||||
onClose();
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(asset: MediaAssetSummary) {
|
||||
startTransition(async () => {
|
||||
const result = await deleteMediaAssetAction(asset.id);
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Couldn't delete that image.");
|
||||
return;
|
||||
}
|
||||
setAssets((prev) => prev.filter((a) => a.id !== asset.id));
|
||||
setSelectedAsset((prev) => (prev?.id === asset.id ? null : prev));
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Media Manager" className="max-w-2xl">
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="library">Library</TabsTrigger>
|
||||
<TabsTrigger value="upload">Upload</TabsTrigger>
|
||||
<TabsTrigger value="url">External URL</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-danger mt-4 text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<TabsContent value="library">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<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
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by filename"
|
||||
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>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{scope === "category" ? "This field" : "All images"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isPending && assets.length === 0 && !error ? (
|
||||
<p className="text-muted py-10 text-center text-sm">Loading…</p>
|
||||
) : assets.length === 0 ? (
|
||||
<div className="flex flex-col items-center py-10 text-center">
|
||||
<ImageOff className="text-muted mb-3 h-8 w-8" aria-hidden="true" />
|
||||
<p className="text-muted text-sm">No images yet — upload one to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid max-h-96 grid-cols-3 gap-3 overflow-y-auto sm:grid-cols-4">
|
||||
{assets.map((asset) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedAsset(asset)}
|
||||
aria-pressed={selectedAsset?.id === asset.id}
|
||||
className={cn(
|
||||
"border-line relative aspect-square overflow-hidden rounded-xl border",
|
||||
selectedAsset?.id === asset.id && "ring-ink ring-2",
|
||||
)}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URLs, same pattern used app-wide */}
|
||||
<img src={asset.url} alt={asset.originalFilename ?? ""} className="h-full w-full object-cover" />
|
||||
{selectedAsset?.id === asset.id && (
|
||||
<span className="bg-ink absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full text-white">
|
||||
<Check className="h-3 w-3" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedAsset && (
|
||||
<div className="bg-stone mt-4 rounded-xl p-4 text-xs">
|
||||
<p className="mb-2 truncate font-bold">{selectedAsset.originalFilename ?? "Untitled"}</p>
|
||||
<dl className="text-muted grid grid-cols-2 gap-y-1">
|
||||
<div className="flex gap-1">
|
||||
<dt className="font-bold">Size:</dt>
|
||||
<dd>{formatBytes(selectedAsset.sizeBytes)}</dd>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<dt className="font-bold">Dimensions:</dt>
|
||||
<dd>{selectedAsset.width && selectedAsset.height ? `${selectedAsset.width}×${selectedAsset.height}` : "Unknown"}</dd>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<dt className="font-bold">Uploaded:</dt>
|
||||
<dd>{new Date(selectedAsset.createdAt).toLocaleDateString("en-GB")}</dd>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<dt className="font-bold">Type:</dt>
|
||||
<dd>{selectedAsset.mimeType}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onSelect(selectedAsset.url);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Use this image
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => navigator.clipboard.writeText(new URL(selectedAsset.url, window.location.origin).toString())}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" aria-hidden="true" /> Copy URL
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="destructive" isLoading={isPending} onClick={() => handleDelete(selectedAsset)}>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="upload">
|
||||
<label className="border-line bg-stone flex cursor-pointer flex-col items-center gap-3 rounded-xl border-2 border-dashed px-6 py-12 text-center">
|
||||
<Upload className="text-muted h-8 w-8" aria-hidden="true" />
|
||||
<span className="text-sm font-bold">Choose an image to upload</span>
|
||||
<span className="text-muted text-xs">PNG, JPEG, WEBP, or GIF — max 5MB</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{isPending && <p className="text-muted mt-3 text-center text-sm">Uploading…</p>}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="url">
|
||||
<Input label="Image URL" placeholder="https://…" value={externalUrl} onChange={(e) => setExternalUrl(e.target.value)} />
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-4 w-full"
|
||||
disabled={!externalUrl.trim()}
|
||||
onClick={() => {
|
||||
onSelect(externalUrl.trim());
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" aria-hidden="true" /> Use this URL
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ImageOff, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MediaManagerModal } from "@/features/media/components/media-manager-modal";
|
||||
|
||||
export interface MediaPickerProps {
|
||||
label?: string;
|
||||
value: string | null | undefined;
|
||||
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". */
|
||||
category: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard image-field component — preview, Change (opens the Media
|
||||
* Manager), Remove. Replaces every plain-text "... URL" Input across the
|
||||
* app (business logo/cover, loyalty card background, staff photo,
|
||||
* service photo, welcome backgrounds, PWA icon/favicon). See
|
||||
* docs/phase-9-summary.md.
|
||||
*/
|
||||
export function MediaPicker({ label, value, onChange, category, hint }: MediaPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && <p className="text-muted mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase">{label}</p>}
|
||||
<div className="border-line flex items-center gap-4 rounded-xl border bg-white p-4">
|
||||
<div className="bg-stone flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
||||
{value ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URLs, same pattern used app-wide
|
||||
<img src={value} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<ImageOff className="text-muted h-5 w-5" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => setOpen(true)}>
|
||||
{value ? "Change" : "Choose image"}
|
||||
</Button>
|
||||
{value && (
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => onChange(null)}>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" /> Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hint && <p className="text-muted mt-1.5 ml-1 text-xs">{hint}</p>}
|
||||
|
||||
<MediaManagerModal open={open} onClose={() => setOpen(false)} onSelect={(url) => onChange(url)} category={category} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { hasPermission } from "@/lib/auth/permissions";
|
||||
import { hasPermission, isSuperAdmin } from "@/lib/auth/permissions";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "../authorization";
|
||||
import { deactivateStaff, updateStaffAsOwner } from "@/services/staff/staff.service";
|
||||
import { deleteCategory } from "@/services/catalogue/service-catalogue.service";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import {
|
||||
cleanupTestBusiness,
|
||||
createTestBusiness,
|
||||
@@ -121,4 +122,46 @@ describe("Owner authorization and business isolation (integration)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Super Administrator (Phase 9)", () => {
|
||||
/** Deliberately no StaffProfile at all — see scripts/ensure-super-admin.ts and docs/phase-9-summary.md. */
|
||||
async function createTestSuperAdmin() {
|
||||
const user = await prisma.user.create({
|
||||
data: { email: `super-admin-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.local`, isSuperAdmin: true },
|
||||
});
|
||||
return user.id;
|
||||
}
|
||||
|
||||
it("isSuperAdmin reflects the User.isSuperAdmin flag", async () => {
|
||||
const { userId: ordinaryUserId } = await createTestStaff(business);
|
||||
const superAdminUserId = await createTestSuperAdmin();
|
||||
try {
|
||||
expect(await isSuperAdmin(ordinaryUserId)).toBe(false);
|
||||
expect(await isSuperAdmin(superAdminUserId)).toBe(true);
|
||||
} finally {
|
||||
await prisma.user.delete({ where: { id: superAdminUserId } });
|
||||
}
|
||||
});
|
||||
|
||||
it("hasPermission grants every permission to a Super Administrator with no StaffProfile at all", async () => {
|
||||
const superAdminUserId = await createTestSuperAdmin();
|
||||
try {
|
||||
for (const permission of Object.values(Permission)) {
|
||||
expect(await hasPermission(superAdminUserId, permission)).toBe(true);
|
||||
}
|
||||
expect(await prisma.staffProfile.findUnique({ where: { userId: superAdminUserId } })).toBeNull();
|
||||
} finally {
|
||||
await prisma.user.delete({ where: { id: superAdminUserId } });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not grant permissions to an ordinary user who merely lacks a StaffProfile", async () => {
|
||||
const user = await prisma.user.create({ data: { email: `plain-${Date.now()}@test.local` } });
|
||||
try {
|
||||
expect(await hasPermission(user.id, Permission.MANAGE_STAFF)).toBe(false);
|
||||
} finally {
|
||||
await prisma.user.delete({ where: { id: user.id } });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { writeAuditLog } from "@/lib/audit/write-audit-log";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import {
|
||||
setOpeningHours,
|
||||
updateBusinessProfile,
|
||||
@@ -17,6 +18,11 @@ export interface BusinessActionResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses getActingBusinessContext (not a raw StaffProfile lookup) so a
|
||||
* Super Administrator — who has no StaffProfile by design, see
|
||||
* docs/phase-9-summary.md — can manage Business Branding too.
|
||||
*/
|
||||
async function requireBusinessManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { staff: null, error: "You must be signed in." } as const;
|
||||
@@ -28,10 +34,10 @@ async function requireBusinessManager() {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const staff = await prisma.staffProfile.findUnique({ where: { userId: session.user.id } });
|
||||
if (!staff) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) return { staff: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { staff, error: null } as const;
|
||||
return { staff: { businessId: context.businessId, userId: context.userId }, error: null } as const;
|
||||
}
|
||||
|
||||
export async function updateBusinessProfileAction(params: {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { Permission } from "@/generated/prisma/enums";
|
||||
import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization";
|
||||
import { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
|
||||
import { updateBusinessProfile, updateBusinessSettings } from "@/services/business/business.service";
|
||||
import { setReviewHidden } from "@/services/reviews/review-management.service";
|
||||
import { syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
|
||||
|
||||
export interface ReviewsActionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
async function requireReviewsManager() {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { businessId: null, error: "You must be signed in." } as const;
|
||||
|
||||
try {
|
||||
await assertOwnerPermission(session.user.id, Permission.MANAGE_REVIEWS);
|
||||
} catch (error) {
|
||||
if (error instanceof OwnerPermissionDeniedError) return { businessId: null, error: error.message } as const;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const context = await getActingBusinessContext();
|
||||
if (!context) return { businessId: null, error: "You must be signed in as staff." } as const;
|
||||
|
||||
return { businessId: context.businessId, 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. */
|
||||
export async function updateReviewsSettingsAction(input: {
|
||||
googlePlaceId: string | null;
|
||||
reviewsMinRating: number | null;
|
||||
reviewsExcludeRatingOnly: boolean;
|
||||
}): Promise<ReviewsActionResult> {
|
||||
const { businessId, error } = await requireReviewsManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const business = await prisma.business.findUnique({ where: { id: businessId } });
|
||||
if (!business) return { success: false, error: "Business not found." };
|
||||
const location = await prisma.businessLocation.findFirst({ where: { businessId, isPrimary: true } });
|
||||
if (!location) return { success: false, error: "Business location not found." };
|
||||
|
||||
const profileResult = await updateBusinessProfile(prisma, {
|
||||
businessId,
|
||||
profile: {
|
||||
name: business.name,
|
||||
timezone: business.timezone,
|
||||
currency: business.currency,
|
||||
locale: business.locale,
|
||||
googlePlaceId: input.googlePlaceId,
|
||||
},
|
||||
location: {
|
||||
addressLine1: location.addressLine1,
|
||||
city: location.city,
|
||||
postalCode: location.postalCode,
|
||||
country: location.country,
|
||||
},
|
||||
});
|
||||
if (!profileResult.success) {
|
||||
return { success: false, error: profileResult.validation.issues[0]?.message ?? "Couldn't save the Google Place ID." };
|
||||
}
|
||||
|
||||
await updateBusinessSettings(prisma, {
|
||||
businessId,
|
||||
settings: { reviewsMinRating: input.reviewsMinRating, reviewsExcludeRatingOnly: input.reviewsExcludeRatingOnly },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function setReviewHiddenAction(reviewId: string, isHidden: boolean): Promise<ReviewsActionResult> {
|
||||
const { businessId, error } = await requireReviewsManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const result = await setReviewHidden(prisma, { reviewId, businessId, isHidden });
|
||||
if (!result.success) return { success: false, error: "That review couldn't be found." };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Manual "sync now" — the scheduled job (/api/cron/sync-google-reviews) covers the normal 24h cadence; this lets an owner see results immediately after entering a Place ID for the first time. */
|
||||
export async function syncReviewsNowAction(): Promise<ReviewsActionResult> {
|
||||
const { businessId, error } = await requireReviewsManager();
|
||||
if (!businessId) return { success: false, error: error ?? "Not authorized." };
|
||||
|
||||
const business = await prisma.business.findUnique({ where: { id: businessId }, select: { googlePlaceId: true } });
|
||||
if (!business?.googlePlaceId) {
|
||||
return { success: false, error: "Set a Google Place ID first." };
|
||||
}
|
||||
|
||||
const result = await syncGoogleReviewsForBusiness(prisma, { businessId, placeId: business.googlePlaceId });
|
||||
if (result.error) return { success: false, error: result.error };
|
||||
return { success: true };
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MediaPicker } from "@/features/media/components/media-picker";
|
||||
import { updateBusinessProfileAction, updateBusinessSettingsAction } from "../actions/business";
|
||||
|
||||
const TIMEZONES = ["Europe/London", "Europe/Paris", "Europe/Dublin", "America/New_York", "America/Los_Angeles"];
|
||||
@@ -15,7 +17,14 @@ const LOCALES = ["en-GB", "en-US"];
|
||||
export interface BusinessDetailsInitial {
|
||||
name: string;
|
||||
description: string;
|
||||
logoUrl: string;
|
||||
logoUrl: string | null;
|
||||
coverImageUrl: string | null;
|
||||
welcomeBackgroundDesktopUrl: string | null;
|
||||
welcomeBackgroundMobileUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
appIconUrl: string | null;
|
||||
themeColor: string | null;
|
||||
backgroundColor: string | null;
|
||||
phone: string;
|
||||
email: string;
|
||||
website: string;
|
||||
@@ -55,7 +64,14 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
||||
profile: {
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
logoUrl: form.logoUrl || undefined,
|
||||
logoUrl: form.logoUrl,
|
||||
coverImageUrl: form.coverImageUrl,
|
||||
welcomeBackgroundDesktopUrl: form.welcomeBackgroundDesktopUrl,
|
||||
welcomeBackgroundMobileUrl: form.welcomeBackgroundMobileUrl,
|
||||
faviconUrl: form.faviconUrl,
|
||||
appIconUrl: form.appIconUrl,
|
||||
themeColor: form.themeColor,
|
||||
backgroundColor: form.backgroundColor,
|
||||
phone: form.phone || undefined,
|
||||
email: form.email || undefined,
|
||||
website: form.website || undefined,
|
||||
@@ -101,11 +117,55 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
||||
<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">
|
||||
<Input label="Business Name" required value={form.name} onChange={(e) => set("name", e.target.value)} />
|
||||
<Input label="Logo URL" placeholder="https://…" value={form.logoUrl} onChange={(e) => set("logoUrl", e.target.value)} />
|
||||
<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)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<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">
|
||||
<MediaPicker
|
||||
label="Cover Image"
|
||||
value={form.coverImageUrl}
|
||||
onChange={(url) => set("coverImageUrl", url)}
|
||||
category="branding"
|
||||
hint="Shown at the top of the customer Home page."
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Welcome Background — Desktop"
|
||||
value={form.welcomeBackgroundDesktopUrl}
|
||||
onChange={(url) => set("welcomeBackgroundDesktopUrl", url)}
|
||||
category="welcome"
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Welcome Background — Mobile"
|
||||
value={form.welcomeBackgroundMobileUrl}
|
||||
onChange={(url) => set("welcomeBackgroundMobileUrl", url)}
|
||||
category="welcome"
|
||||
hint="Shown on the sign-in/welcome screen, before someone signs in."
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Favicon"
|
||||
value={form.faviconUrl}
|
||||
onChange={(url) => set("faviconUrl", url)}
|
||||
category="branding"
|
||||
hint="Browser tab icon. Falls back to the app default when unset."
|
||||
/>
|
||||
<MediaPicker
|
||||
label="App Icon"
|
||||
value={form.appIconUrl}
|
||||
onChange={(url) => set("appIconUrl", url)}
|
||||
category="branding"
|
||||
hint="Used when someone installs this as an app on their phone."
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ColorPicker label="Theme Colour" value={form.themeColor} onChange={(hex) => set("themeColor", hex)} />
|
||||
<ColorPicker label="Background Colour" value={form.backgroundColor} onChange={(hex) => set("backgroundColor", hex)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Contact & Address</h3>
|
||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface CatalogueService {
|
||||
categoryId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl: string | null;
|
||||
durationMinutes: number;
|
||||
price: string;
|
||||
bufferBeforeMinutes: number;
|
||||
@@ -114,6 +115,7 @@ export function CatalogueManager({ categories }: { categories: CatalogueCategory
|
||||
categoryId: service.categoryId,
|
||||
name: service.name,
|
||||
description: service.description,
|
||||
imageUrl: service.imageUrl,
|
||||
durationMinutes: service.durationMinutes,
|
||||
price: service.price,
|
||||
bufferBeforeMinutes: service.bufferBeforeMinutes,
|
||||
@@ -220,14 +222,20 @@ export function CatalogueManager({ categories }: { categories: CatalogueCategory
|
||||
<ChevronDown className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => openEditService(service)} className="min-w-0 flex-1 text-left">
|
||||
<p className={service.isActive ? "text-sm font-bold" : "text-muted text-sm font-bold line-through"}>
|
||||
{service.name}
|
||||
</p>
|
||||
<p className="text-muted mt-0.5 text-xs">
|
||||
{service.durationMinutes} min · {formatPrice(service.price)}
|
||||
{!service.allowOnlineBooking && " · Not bookable online"}
|
||||
</p>
|
||||
<button type="button" onClick={() => openEditService(service)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
|
||||
{service.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide
|
||||
<img src={service.imageUrl} alt="" className="bg-stone h-10 w-10 shrink-0 rounded-lg object-cover" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<p className={service.isActive ? "text-sm font-bold" : "text-muted text-sm font-bold line-through"}>
|
||||
{service.name}
|
||||
</p>
|
||||
<p className="text-muted mt-0.5 text-xs">
|
||||
{service.durationMinutes} min · {formatPrice(service.price)}
|
||||
{!service.allowOnlineBooking && " · Not bookable online"}
|
||||
</p>
|
||||
</span>
|
||||
</button>
|
||||
<Switch
|
||||
checked={service.isActive}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChevronDown, ChevronUp, Plus, Trash2 } from "lucide-react";
|
||||
import { GalleryPlacement } from "@/generated/prisma/enums";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { MediaManagerModal } from "@/features/media/components/media-manager-modal";
|
||||
import {
|
||||
addGalleryImageAction,
|
||||
deleteGalleryImageAction,
|
||||
reorderGalleryImagesAction,
|
||||
updateGalleryImageAction,
|
||||
} from "@/features/gallery/actions/gallery";
|
||||
import type { GalleryImageSummary } from "@/services/gallery/gallery.service";
|
||||
|
||||
export interface GalleryManagerProps {
|
||||
placement: GalleryPlacement;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Portfolio images carry a category tag for the customer-facing filter pills; the hero gallery has no use for one. */
|
||||
showCategory: boolean;
|
||||
initialImages: GalleryImageSummary[];
|
||||
}
|
||||
|
||||
export function GalleryManager({ placement, title, description, showCategory, initialImages }: GalleryManagerProps) {
|
||||
const router = useRouter();
|
||||
const [images, setImages] = useState(initialImages);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const run = (fn: () => Promise<{ success: boolean; error?: string }>) => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await fn();
|
||||
if (!result.success) {
|
||||
setError(result.error ?? "Something went wrong.");
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelect = (url: string) => {
|
||||
run(async () => {
|
||||
const result = await addGalleryImageAction({ placement, imageUrl: url });
|
||||
if (result.success) setImages((prev) => [...prev, result.image]);
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
const move = (index: number, direction: -1 | 1) => {
|
||||
const targetIndex = index + direction;
|
||||
if (targetIndex < 0 || targetIndex >= images.length) return;
|
||||
const reordered = [...images];
|
||||
const [moved] = reordered.splice(index, 1);
|
||||
if (!moved) return;
|
||||
reordered.splice(targetIndex, 0, moved);
|
||||
setImages(reordered);
|
||||
run(() => reorderGalleryImagesAction(placement, reordered.map((i) => i.id)));
|
||||
};
|
||||
|
||||
const remove = (id: string) => {
|
||||
setImages((prev) => prev.filter((i) => i.id !== id));
|
||||
run(() => deleteGalleryImageAction(id));
|
||||
};
|
||||
|
||||
const setCategory = (id: string, category: string) => {
|
||||
setImages((prev) => prev.map((i) => (i.id === id ? { ...i, category } : i)));
|
||||
run(() => updateGalleryImageAction(id, { category }));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">{title}</h3>
|
||||
<p className="text-muted mt-1 ml-1 text-xs">{description}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{images.map((image, index) => (
|
||||
<div key={image.id} className="border-line overflow-hidden rounded-xl border bg-white">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- local/user-supplied URL, same pattern used app-wide */}
|
||||
<img src={image.imageUrl} alt="" className="bg-stone h-28 w-full object-cover" />
|
||||
<div className="space-y-2 p-2">
|
||||
{showCategory && (
|
||||
<Input
|
||||
value={image.category ?? ""}
|
||||
onChange={(e) => setCategory(image.id, e.target.value)}
|
||||
placeholder="Category (optional)"
|
||||
className="px-3 py-2 text-xs"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={index === 0}
|
||||
aria-label="Move earlier"
|
||||
className="text-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={index === images.length - 1}
|
||||
aria-label="Move later"
|
||||
className="text-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" onClick={() => remove(image.id)} aria-label="Delete image" className="text-danger">
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
disabled={isPending}
|
||||
className="border-line text-muted flex h-full min-h-[9rem] flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed"
|
||||
>
|
||||
<Plus className="h-5 w-5" aria-hidden="true" />
|
||||
<span className="text-xs font-bold tracking-widest uppercase">Add Image</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MediaManagerModal
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onSelect={handleSelect}
|
||||
category={placement === GalleryPlacement.HERO ? "gallery" : "portfolio"}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -4,9 +4,13 @@ import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { IconPicker } from "@/components/ui/icon-picker";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { LoyaltyCard } from "@/components/ui/loyalty-card";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { MediaPicker } from "@/features/media/components/media-picker";
|
||||
import { updateLoyaltyConfigAction } from "../actions/loyalty-config";
|
||||
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
|
||||
|
||||
@@ -17,6 +21,7 @@ const REWARD_TYPES: { value: ProgrammeConfigInput["rewardType"]; label: string }
|
||||
];
|
||||
|
||||
export interface LoyaltyConfigFormProps {
|
||||
businessName: string;
|
||||
initialConfig: ProgrammeConfigInput;
|
||||
initialEligibleServiceIds: string[];
|
||||
initialEligibleStaffIds: string[];
|
||||
@@ -25,6 +30,7 @@ export interface LoyaltyConfigFormProps {
|
||||
}
|
||||
|
||||
export function LoyaltyConfigForm({
|
||||
businessName,
|
||||
initialConfig,
|
||||
initialEligibleServiceIds,
|
||||
initialEligibleStaffIds,
|
||||
@@ -191,13 +197,43 @@ export function LoyaltyConfigForm({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<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>
|
||||
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
||||
<Input label="Card background color" value={config.cardBackgroundColor ?? ""} onChange={(e) => set("cardBackgroundColor", e.target.value)} placeholder="#111b15" />
|
||||
<Input label="Card background image URL (optional)" value={config.cardBackgroundImageUrl ?? ""} onChange={(e) => set("cardBackgroundImageUrl", e.target.value)} />
|
||||
<Input label="Stamp icon" value={config.stampIcon ?? ""} onChange={(e) => set("stampIcon", e.target.value)} placeholder="scissors" />
|
||||
<Input label="Terms line" value={config.termsLine ?? ""} onChange={(e) => set("termsLine", e.target.value)} placeholder="Valid for 12 months. One reward per visit." />
|
||||
<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>
|
||||
<p className="text-muted mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase">Live Preview</p>
|
||||
<LoyaltyCard
|
||||
businessName={businessName}
|
||||
memberNumber="A1B2C3"
|
||||
stampsRequired={Math.max(config.stampsRequired, 1)}
|
||||
stampsEarned={Math.min(3, Math.max(config.stampsRequired - 1, 0))}
|
||||
rewardLabel={config.rewardLabel || "Reward"}
|
||||
termsLine={config.termsLine}
|
||||
cardBackgroundColor={config.cardBackgroundColor}
|
||||
cardBackgroundImageUrl={config.cardBackgroundImageUrl}
|
||||
stampIcon={config.stampIcon}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4 lg:mt-0">
|
||||
<ColorPicker
|
||||
label="Background colour"
|
||||
value={config.cardBackgroundColor}
|
||||
onChange={(hex) => set("cardBackgroundColor", hex)}
|
||||
/>
|
||||
<MediaPicker
|
||||
label="Background image (optional)"
|
||||
value={config.cardBackgroundImageUrl}
|
||||
onChange={(url) => set("cardBackgroundImageUrl", url ?? undefined)}
|
||||
category="loyalty"
|
||||
/>
|
||||
<IconPicker label="Stamp icon" value={config.stampIcon} onChange={(iconName) => set("stampIcon", iconName)} />
|
||||
<Input
|
||||
label="Terms line"
|
||||
value={config.termsLine ?? ""}
|
||||
onChange={(e) => set("termsLine", e.target.value)}
|
||||
placeholder="Valid for 12 months. One reward per visit."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useTransition } from "react";
|
||||
import { RefreshCw, Star, StarOff } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import { setReviewHiddenAction, syncReviewsNowAction, updateReviewsSettingsAction } from "../actions/reviews";
|
||||
|
||||
export interface OwnerReviewRow {
|
||||
id: string;
|
||||
authorName: string;
|
||||
rating: number;
|
||||
text: string | null;
|
||||
relativeTimeDescription: string | null;
|
||||
isHidden: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewsManagerProps {
|
||||
initialGooglePlaceId: string;
|
||||
initialReviewsMinRating: number | null;
|
||||
initialReviewsExcludeRatingOnly: boolean;
|
||||
googleRating: number | null;
|
||||
googleRatingCount: number | null;
|
||||
reviews: OwnerReviewRow[];
|
||||
}
|
||||
|
||||
const RATING_FILTERS = [null, 5, 4, 3, 2, 1] as const;
|
||||
|
||||
function StarRating({ rating }: { rating: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5" aria-label={`${rating} out of 5 stars`}>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={cn("h-3.5 w-3.5", i < rating ? "fill-accent-loyalty text-accent-loyalty" : "text-line")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReviewsManager({
|
||||
initialGooglePlaceId,
|
||||
initialReviewsMinRating,
|
||||
initialReviewsExcludeRatingOnly,
|
||||
googleRating,
|
||||
googleRatingCount,
|
||||
reviews,
|
||||
}: ReviewsManagerProps) {
|
||||
const router = useRouter();
|
||||
const [googlePlaceId, setGooglePlaceId] = useState(initialGooglePlaceId);
|
||||
const [reviewsMinRating, setReviewsMinRating] = useState(initialReviewsMinRating);
|
||||
const [reviewsExcludeRatingOnly, setReviewsExcludeRatingOnly] = useState(initialReviewsExcludeRatingOnly);
|
||||
const [settingsError, setSettingsError] = useState<string | null>(null);
|
||||
const [settingsSaved, setSettingsSaved] = useState(false);
|
||||
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||
const [isSettingsPending, startSettingsTransition] = useTransition();
|
||||
const [isSyncPending, startSyncTransition] = useTransition();
|
||||
|
||||
const [ratingFilter, setRatingFilter] = useState<number | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [rows, setRows] = useState(reviews);
|
||||
const [hidingId, setHidingId] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return rows.filter((r) => {
|
||||
if (ratingFilter && r.rating !== ratingFilter) return false;
|
||||
if (q && !(r.authorName.toLowerCase().includes(q) || (r.text ?? "").toLowerCase().includes(q))) return false;
|
||||
return true;
|
||||
});
|
||||
}, [rows, ratingFilter, query]);
|
||||
|
||||
const saveSettings = () => {
|
||||
setSettingsError(null);
|
||||
setSettingsSaved(false);
|
||||
startSettingsTransition(async () => {
|
||||
const result = await updateReviewsSettingsAction({
|
||||
googlePlaceId: googlePlaceId.trim() || null,
|
||||
reviewsMinRating,
|
||||
reviewsExcludeRatingOnly,
|
||||
});
|
||||
if (!result.success) {
|
||||
setSettingsError(result.error ?? "Couldn't save review settings.");
|
||||
return;
|
||||
}
|
||||
setSettingsSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const syncNow = () => {
|
||||
setSyncMessage(null);
|
||||
startSyncTransition(async () => {
|
||||
const result = await syncReviewsNowAction();
|
||||
setSyncMessage(result.success ? "Synced — refreshing…" : (result.error ?? "Sync failed."));
|
||||
if (result.success) router.refresh();
|
||||
});
|
||||
};
|
||||
|
||||
const toggleHidden = (review: OwnerReviewRow) => {
|
||||
setHidingId(review.id);
|
||||
const nextHidden = !review.isHidden;
|
||||
setRows((prev) => prev.map((r) => (r.id === review.id ? { ...r, isHidden: nextHidden } : r)));
|
||||
startSettingsTransition(async () => {
|
||||
const result = await setReviewHiddenAction(review.id, nextHidden);
|
||||
if (!result.success) {
|
||||
// Revert on failure.
|
||||
setRows((prev) => prev.map((r) => (r.id === review.id ? { ...r, isHidden: review.isHidden } : r)));
|
||||
}
|
||||
setHidingId(null);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-jumbo border-line space-y-4 border bg-white p-6">
|
||||
<h3 className="text-sm font-bold">Google Reviews Sync</h3>
|
||||
{googleRating !== null && (
|
||||
<p className="text-muted text-sm">
|
||||
Google's aggregate: <span className="text-ink font-bold">{googleRating.toFixed(1)}</span> from{" "}
|
||||
{googleRatingCount ?? 0} ratings.
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
label="Google Place ID"
|
||||
value={googlePlaceId}
|
||||
onChange={(e) => setGooglePlaceId(e.target.value)}
|
||||
placeholder="ChIJ…"
|
||||
hint="Found via Google's Place ID Finder. Leave blank to disable review syncing for this business."
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="Minimum rating shown to customers"
|
||||
value={reviewsMinRating?.toString() ?? ""}
|
||||
onChange={(e) => setReviewsMinRating(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<option value="">No minimum</option>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}+ stars
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<div className="border-line flex items-center rounded-xl border p-4">
|
||||
<Switch
|
||||
label="Exclude rating-only reviews"
|
||||
checked={reviewsExcludeRatingOnly}
|
||||
onCheckedChange={setReviewsExcludeRatingOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{settingsError && (
|
||||
<p role="alert" className="text-danger text-sm font-medium">
|
||||
{settingsError}
|
||||
</p>
|
||||
)}
|
||||
{settingsSaved && !settingsError && (
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button isLoading={isSettingsPending} onClick={saveSettings}>
|
||||
Save
|
||||
</Button>
|
||||
<Button variant="secondary" isLoading={isSyncPending} onClick={syncNow}>
|
||||
<RefreshCw className="h-4 w-4" aria-hidden="true" /> Sync Now
|
||||
</Button>
|
||||
</div>
|
||||
{syncMessage && <p className="text-muted text-xs">{syncMessage}</p>}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Imported Reviews</h3>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{RATING_FILTERS.map((r) => (
|
||||
<button
|
||||
key={r ?? "all"}
|
||||
type="button"
|
||||
onClick={() => setRatingFilter(r)}
|
||||
className={cn(
|
||||
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
|
||||
ratingFilter === r ? "bg-ink text-white" : "border-line text-muted border bg-white",
|
||||
)}
|
||||
>
|
||||
{r ? `${r}★` : "All"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by author or review text"
|
||||
className="border-line focus:ring-ink w-full rounded-xl border bg-white px-4 py-2.5 text-sm outline-none focus:ring-2"
|
||||
/>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted py-6 text-center text-sm">No reviews match this filter.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filtered.map((review) => (
|
||||
<Card key={review.id} className={cn("space-y-2 p-5", review.isHidden && "opacity-60")}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-bold">{review.authorName}</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<StarRating rating={review.rating} />
|
||||
{review.relativeTimeDescription && (
|
||||
<span className="text-muted text-xs">{review.relativeTimeDescription}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isLoading={hidingId === review.id}
|
||||
onClick={() => toggleHidden(review)}
|
||||
>
|
||||
{review.isHidden ? (
|
||||
<>
|
||||
<Star className="h-3.5 w-3.5" aria-hidden="true" /> Unhide
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<StarOff className="h-3.5 w-3.5" aria-hidden="true" /> Hide
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{review.text && <p className="text-muted text-sm">{review.text}</p>}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MediaPicker } from "@/features/media/components/media-picker";
|
||||
import { createServiceAction, updateServiceAction } from "../actions/catalogue";
|
||||
|
||||
export interface EditableService {
|
||||
@@ -15,6 +16,7 @@ export interface EditableService {
|
||||
categoryId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
imageUrl: string | null;
|
||||
durationMinutes: number;
|
||||
price: string;
|
||||
bufferBeforeMinutes: number;
|
||||
@@ -39,6 +41,7 @@ export function ServiceFormSheet({
|
||||
const [categoryId, setCategoryId] = useState(existing?.categoryId ?? defaultCategoryId);
|
||||
const [name, setName] = useState(existing?.name ?? "");
|
||||
const [description, setDescription] = useState(existing?.description ?? "");
|
||||
const [imageUrl, setImageUrl] = useState(existing?.imageUrl ?? "");
|
||||
const [duration, setDuration] = useState(String(existing?.durationMinutes ?? 30));
|
||||
const [price, setPrice] = useState(existing?.price ?? "");
|
||||
const [bufferBefore, setBufferBefore] = useState(String(existing?.bufferBeforeMinutes ?? 0));
|
||||
@@ -61,6 +64,7 @@ export function ServiceFormSheet({
|
||||
categoryId,
|
||||
name,
|
||||
description: description || undefined,
|
||||
imageUrl: imageUrl || undefined,
|
||||
durationMinutes,
|
||||
price,
|
||||
bufferBeforeMinutes,
|
||||
@@ -89,6 +93,7 @@ export function ServiceFormSheet({
|
||||
</Select>
|
||||
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Textarea label="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
<MediaPicker label="Photo (optional)" value={imageUrl} onChange={(url) => setImageUrl(url ?? "")} category="service" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input label="Duration (min)" type="number" min={1} value={duration} onChange={(e) => setDuration(e.target.value)} />
|
||||
<Input label="Price" placeholder="22.50" value={price} onChange={(e) => setPrice(e.target.value)} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MediaPicker } from "@/features/media/components/media-picker";
|
||||
import { updateProfileAction } from "../actions/profile";
|
||||
|
||||
export interface ProfileFormInitial {
|
||||
@@ -51,12 +52,7 @@ export function ProfileForm({ initial }: { initial: ProfileFormInitial }) {
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Photo URL"
|
||||
placeholder="https://…"
|
||||
value={image}
|
||||
onChange={(e) => setImage(e.target.value)}
|
||||
/>
|
||||
<MediaPicker label="Photo" value={image} onChange={(url) => setImage(url ?? "")} category="staff" />
|
||||
<Textarea
|
||||
label="Bio"
|
||||
placeholder="A short introduction customers will see on your profile"
|
||||
|
||||
@@ -26,7 +26,27 @@ export const getStaffPermissions = cache(async (userId: string): Promise<Set<Per
|
||||
return new Set(staffProfile.role.permissions.map((rolePermission) => rolePermission.permission));
|
||||
});
|
||||
|
||||
/**
|
||||
* Phase 9 — the platform-level Super Administrator (see
|
||||
* scripts/ensure-super-admin.ts). Deliberately re-checks the database
|
||||
* rather than trusting the JWT, same rationale as getStaffPermissions.
|
||||
*/
|
||||
export const isSuperAdmin = cache(async (userId: string): Promise<boolean> => {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { isSuperAdmin: true } });
|
||||
return user?.isSuperAdmin ?? false;
|
||||
});
|
||||
|
||||
/**
|
||||
* A Super Administrator has every permission an Owner has — OWNER_PERMISSIONS
|
||||
* (src/lib/roles/default-permissions.ts) already IS the full Permission enum,
|
||||
* so short-circuiting true here rather than granting a StaffProfile/Role is
|
||||
* exactly equivalent, without giving the Super Admin a StaffProfile row at
|
||||
* all (see docs/phase-9-summary.md, "Super Administrator" — this is what
|
||||
* keeps them out of the Team list, barber picker, and appointment FKs
|
||||
* structurally rather than via a filter).
|
||||
*/
|
||||
export async function hasPermission(userId: string, permission: Permission): Promise<boolean> {
|
||||
if (await isSuperAdmin(userId)) return true;
|
||||
const permissions = await getStaffPermissions(userId);
|
||||
return permissions.has(permission);
|
||||
}
|
||||
@@ -36,6 +36,9 @@ export interface BrandingInfo {
|
||||
appIconUrl: string | null;
|
||||
themeColor: string;
|
||||
backgroundColor: string;
|
||||
/** Welcome page (/) background — see src/app/page.tsx. Independent images, not one image cropped two ways. */
|
||||
welcomeBackgroundDesktopUrl: string | null;
|
||||
welcomeBackgroundMobileUrl: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
@@ -55,6 +58,8 @@ const UNCONFIGURED_BRANDING: BrandingInfo = {
|
||||
appIconUrl: null,
|
||||
themeColor: DEFAULT_THEME_COLOR,
|
||||
backgroundColor: DEFAULT_BACKGROUND_COLOR,
|
||||
welcomeBackgroundDesktopUrl: null,
|
||||
welcomeBackgroundMobileUrl: null,
|
||||
email: null,
|
||||
phone: null,
|
||||
};
|
||||
@@ -80,6 +85,8 @@ export const getBranding = cache(async (): Promise<BrandingInfo> => {
|
||||
appIconUrl: true,
|
||||
themeColor: true,
|
||||
backgroundColor: true,
|
||||
welcomeBackgroundDesktopUrl: true,
|
||||
welcomeBackgroundMobileUrl: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
},
|
||||
@@ -100,6 +107,8 @@ export const getBranding = cache(async (): Promise<BrandingInfo> => {
|
||||
appIconUrl: business.appIconUrl,
|
||||
themeColor: business.themeColor || DEFAULT_THEME_COLOR,
|
||||
backgroundColor: business.backgroundColor || DEFAULT_BACKGROUND_COLOR,
|
||||
welcomeBackgroundDesktopUrl: business.welcomeBackgroundDesktopUrl,
|
||||
welcomeBackgroundMobileUrl: business.welcomeBackgroundMobileUrl,
|
||||
email: business.email,
|
||||
phone: business.phone,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cache } from "react";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { isSuperAdmin } from "@/lib/auth/permissions";
|
||||
import { getPrimaryBusiness } from "@/lib/business/get-primary-business";
|
||||
|
||||
/**
|
||||
* Resolves "which business is the signed-in user acting on behalf of" for
|
||||
* pages/actions that only need the business id/name/timezone — not full
|
||||
* staff identity (see getActingStaff for that, which stays the right
|
||||
* choice for anything that needs a real StaffProfile id, e.g. loyalty
|
||||
* stamp authorship or appointment reassignment).
|
||||
*
|
||||
* A regular staff member/owner resolves via their StaffProfile, same as
|
||||
* getActingStaff. A Super Administrator (see scripts/ensure-super-admin.ts)
|
||||
* deliberately has no StaffProfile at all — that's what keeps them out of
|
||||
* the Team list/barber picker/appointment FKs by construction rather than
|
||||
* a filter — so they resolve straight to the app's one business instead
|
||||
* (v1 is single-business, same assumption getPrimaryBusiness() already
|
||||
* makes everywhere else). Returns null only for a signed-in user who is
|
||||
* neither staff nor a super admin.
|
||||
*/
|
||||
export const getActingBusinessContext = cache(async () => {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return null;
|
||||
|
||||
const staffProfile = await prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
select: { businessId: true, business: { select: { name: true, timezone: true } } },
|
||||
});
|
||||
if (staffProfile) {
|
||||
return {
|
||||
userId: session.user.id,
|
||||
businessId: staffProfile.businessId,
|
||||
businessName: staffProfile.business.name,
|
||||
businessTimezone: staffProfile.business.timezone,
|
||||
};
|
||||
}
|
||||
|
||||
if (!(await isSuperAdmin(session.user.id))) return null;
|
||||
|
||||
const business = await getPrimaryBusiness();
|
||||
return {
|
||||
userId: session.user.id,
|
||||
businessId: business.id,
|
||||
businessName: business.name,
|
||||
businessTimezone: business.timezone,
|
||||
};
|
||||
});
|
||||
|
||||
export type ActingBusinessContext = NonNullable<Awaited<ReturnType<typeof getActingBusinessContext>>>;
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import {
|
||||
faAward,
|
||||
faBell,
|
||||
faBolt,
|
||||
faBriefcase,
|
||||
faBrush,
|
||||
faCalendarCheck,
|
||||
faCamera,
|
||||
faCheck,
|
||||
faCircleCheck,
|
||||
faClock,
|
||||
faCoffee,
|
||||
faCompass,
|
||||
faCrown,
|
||||
faDiamond,
|
||||
faFire,
|
||||
faGem,
|
||||
faGift,
|
||||
faGlassCheers,
|
||||
faHandSparkles,
|
||||
faHatCowboy,
|
||||
faHeart,
|
||||
faLeaf,
|
||||
faLightbulb,
|
||||
faMedal,
|
||||
faMugSaucer,
|
||||
faPaintbrush,
|
||||
faRibbon,
|
||||
faScissors,
|
||||
faShieldHeart,
|
||||
faSprayCanSparkles,
|
||||
faStamp,
|
||||
faStar,
|
||||
faStarHalfStroke,
|
||||
faSun,
|
||||
faTag,
|
||||
faThumbsUp,
|
||||
faTicket,
|
||||
faTrophy,
|
||||
faUserTie,
|
||||
faWandMagicSparkles,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
|
||||
/**
|
||||
* Curated, not the full ~2000-icon Font Awesome solid set — deliberately:
|
||||
* a searchable picker over every icon Font Awesome ships (razors,
|
||||
* spreadsheets, ambulances...) is a worse experience for "pick a loyalty
|
||||
* stamp icon" than a relevant shortlist, and named imports here (rather
|
||||
* than `import { fas } from "..."`) keep only these icons' path data in
|
||||
* the bundle — this component is only ever loaded on the owner Loyalty
|
||||
* settings page, never the customer bundle, but there's no reason to
|
||||
* bundle 2000 unused icons regardless. Add to this list as new use cases
|
||||
* come up; nothing about IconPicker itself is tied to this specific set.
|
||||
*/
|
||||
export const ICON_CATALOG: IconDefinition[] = [
|
||||
faScissors,
|
||||
faStar,
|
||||
faStarHalfStroke,
|
||||
faHeart,
|
||||
faGift,
|
||||
faCrown,
|
||||
faGem,
|
||||
faDiamond,
|
||||
faAward,
|
||||
faMedal,
|
||||
faTrophy,
|
||||
faRibbon,
|
||||
faStamp,
|
||||
faTag,
|
||||
faTicket,
|
||||
faThumbsUp,
|
||||
faCheck,
|
||||
faCircleCheck,
|
||||
faFire,
|
||||
faBolt,
|
||||
faSun,
|
||||
faLeaf,
|
||||
faCoffee,
|
||||
faMugSaucer,
|
||||
faGlassCheers,
|
||||
faHandSparkles,
|
||||
faSprayCanSparkles,
|
||||
faWandMagicSparkles,
|
||||
faBrush,
|
||||
faPaintbrush,
|
||||
faCamera,
|
||||
faHatCowboy,
|
||||
faUserTie,
|
||||
faBriefcase,
|
||||
faShieldHeart,
|
||||
faLightbulb,
|
||||
faCompass,
|
||||
faClock,
|
||||
faCalendarCheck,
|
||||
faBell,
|
||||
];
|
||||
|
||||
const ICON_BY_NAME = new Map<string, IconDefinition>(ICON_CATALOG.map((icon) => [icon.iconName, icon]));
|
||||
|
||||
/** Falls back to a neutral default for anything not in the catalogue — covers legacy free-text values from before this picker existed. */
|
||||
export function findCatalogIcon(iconName: string | null | undefined): IconDefinition {
|
||||
return (iconName && ICON_BY_NAME.get(iconName)) || faScissors;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* needed). A deliberate v1 choice for this app's single-container Docker
|
||||
* deployment model, not a cloud object-storage integration — see
|
||||
* docker-compose.local.yaml's media volume for why this survives a
|
||||
* container restart. Swapping to S3-compatible storage later only means
|
||||
* changing this one file; every caller only ever sees a public URL.
|
||||
*
|
||||
* SVG is deliberately not in the allow-list — an uploaded SVG can embed
|
||||
* <script>, making it a stored-XSS vector if ever served inline.
|
||||
*/
|
||||
const ALLOWED_MIME_EXTENSIONS: Record<string, string> = {
|
||||
"image/png": "png",
|
||||
"image/jpeg": "jpg",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
};
|
||||
|
||||
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");
|
||||
const MEDIA_URL_PREFIX = "/media/";
|
||||
|
||||
export function isAllowedMediaMimeType(mimeType: string): boolean {
|
||||
return mimeType in ALLOWED_MIME_EXTENSIONS;
|
||||
}
|
||||
|
||||
export interface StoredMediaFile {
|
||||
/** Public path — what gets saved on MediaAsset.url and rendered directly. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
function sanitizeCategory(category: string): string {
|
||||
return category.replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "other";
|
||||
}
|
||||
|
||||
export async function storeMediaFile(params: {
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
category: string;
|
||||
}): Promise<StoredMediaFile> {
|
||||
const extension = ALLOWED_MIME_EXTENSIONS[params.mimeType];
|
||||
if (!extension) throw new Error(`Unsupported media type: ${params.mimeType}`);
|
||||
|
||||
const safeCategory = sanitizeCategory(params.category);
|
||||
const filename = `${randomUUID()}.${extension}`;
|
||||
const dir = path.join(MEDIA_ROOT, safeCategory);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(path.join(dir, filename), params.buffer);
|
||||
|
||||
return { url: `${MEDIA_URL_PREFIX}${safeCategory}/${filename}` };
|
||||
}
|
||||
|
||||
/** Best-effort — a missing file (already deleted, or a URL that was never ours) never blocks removing the DB row. */
|
||||
export async function deleteMediaFile(url: string): Promise<void> {
|
||||
if (!url.startsWith(MEDIA_URL_PREFIX)) return;
|
||||
const relative = url.slice(MEDIA_URL_PREFIX.length);
|
||||
const absolutePath = path.join(MEDIA_ROOT, relative);
|
||||
// Path-traversal guard — a corrupted/crafted url should never resolve outside MEDIA_ROOT.
|
||||
if (!absolutePath.startsWith(MEDIA_ROOT + path.sep)) return;
|
||||
await unlink(absolutePath).catch(() => {});
|
||||
}
|
||||
@@ -25,6 +25,8 @@ export const OWNER_PERMISSIONS: Permission[] = [
|
||||
Permission.VIEW_CUSTOMER_DIRECTORY,
|
||||
Permission.BOOK_ON_BEHALF_OF_CUSTOMER,
|
||||
Permission.VIEW_REPORTING,
|
||||
Permission.MANAGE_MEDIA_LIBRARY,
|
||||
Permission.MANAGE_REVIEWS,
|
||||
];
|
||||
|
||||
export const BARBER_PERMISSIONS: Permission[] = [
|
||||
|
||||
@@ -21,7 +21,22 @@ export type BusinessServiceErrorCode =
|
||||
export interface BusinessProfileInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
/**
|
||||
* Image/colour branding fields are `string | null` (not `| undefined`)
|
||||
* and always included on the update payload — see updateBusinessProfile
|
||||
* below — so MediaPicker/ColorPicker's "Remove" actually clears the
|
||||
* column to NULL instead of silently being skipped.
|
||||
*/
|
||||
logoUrl?: string | null;
|
||||
coverImageUrl?: string | null;
|
||||
faviconUrl?: string | null;
|
||||
appIconUrl?: string | null;
|
||||
themeColor?: string | null;
|
||||
backgroundColor?: string | null;
|
||||
welcomeBackgroundDesktopUrl?: string | null;
|
||||
welcomeBackgroundMobileUrl?: string | null;
|
||||
/** Google Places Place ID reviews sync from — see src/services/reviews/review-sync.service.ts. Null/empty disables sync for this business. */
|
||||
googlePlaceId?: string | null;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
@@ -48,6 +63,9 @@ export interface BusinessSettingsInput {
|
||||
lateArrivalPolicy?: string;
|
||||
bookingTerms?: string;
|
||||
privacyPolicyUrl?: string;
|
||||
/** Reviews visibility policy — see src/services/reviews. Applied on top of each Review's own isHidden flag. */
|
||||
reviewsMinRating?: number | null;
|
||||
reviewsExcludeRatingOnly?: boolean;
|
||||
}
|
||||
|
||||
export function validateBusinessProfileInput(
|
||||
|
||||
@@ -153,6 +153,8 @@ export interface ServiceInput {
|
||||
categoryId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** Set via the Media Manager — see src/features/media/components/media-picker.tsx. */
|
||||
imageUrl?: string;
|
||||
durationMinutes: number;
|
||||
/** Decimal string, e.g. "22.50" — never a JS number, see file header. */
|
||||
price: string;
|
||||
@@ -226,6 +228,7 @@ export async function createService(
|
||||
categoryId: params.input.categoryId,
|
||||
name: params.input.name.trim(),
|
||||
description: params.input.description?.trim() || null,
|
||||
imageUrl: params.input.imageUrl?.trim() || null,
|
||||
durationMinutes: params.input.durationMinutes,
|
||||
price: params.input.price,
|
||||
bufferBeforeMinutes: params.input.bufferBeforeMinutes,
|
||||
@@ -286,6 +289,7 @@ export async function updateService(
|
||||
categoryId: params.input.categoryId,
|
||||
name: params.input.name.trim(),
|
||||
description: params.input.description?.trim() || null,
|
||||
imageUrl: params.input.imageUrl?.trim() || null,
|
||||
durationMinutes: params.input.durationMinutes,
|
||||
price: params.input.price,
|
||||
bufferBeforeMinutes: params.input.bufferBeforeMinutes,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { GalleryPlacement } from "@/generated/prisma/enums";
|
||||
import {
|
||||
addGalleryImage,
|
||||
deleteGalleryImage,
|
||||
listGalleryImages,
|
||||
reorderGalleryImages,
|
||||
updateGalleryImage,
|
||||
} from "@/services/gallery/gallery.service";
|
||||
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
describe("Gallery service (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
it("keeps HERO and PORTFOLIO images in separate lists for the same business", async () => {
|
||||
await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.HERO, imageUrl: "/media/gallery/hero-1.png" });
|
||||
await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/gallery/portfolio-1.png", category: "Fades" });
|
||||
|
||||
const hero = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.HERO });
|
||||
const portfolio = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO });
|
||||
|
||||
expect(hero).toHaveLength(1);
|
||||
expect(portfolio).toHaveLength(1);
|
||||
expect(portfolio[0]?.category).toBe("Fades");
|
||||
});
|
||||
|
||||
it("assigns increasing displayOrder on add, and reorder fully replaces it", async () => {
|
||||
const a = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/a.png" });
|
||||
const b = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/b.png" });
|
||||
expect(a.displayOrder).toBe(0);
|
||||
expect(b.displayOrder).toBe(1);
|
||||
|
||||
await reorderGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, orderedIds: [b.id, a.id] });
|
||||
|
||||
const reordered = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO });
|
||||
expect(reordered.map((i) => i.id)).toEqual([b.id, a.id]);
|
||||
});
|
||||
|
||||
it("updates category/caption/isActive, and activeOnly filtering respects isActive", async () => {
|
||||
const image = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/a.png" });
|
||||
await updateGalleryImage(prisma, { id: image.id, businessId: business.businessId, category: "Beards", caption: "Client X", isActive: false });
|
||||
|
||||
const all = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO });
|
||||
expect(all[0]?.category).toBe("Beards");
|
||||
expect(all[0]?.caption).toBe("Client X");
|
||||
expect(all[0]?.isActive).toBe(false);
|
||||
|
||||
const activeOnly = await listGalleryImages(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, activeOnly: true });
|
||||
expect(activeOnly).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never lets one business update or delete another business's gallery image", async () => {
|
||||
const otherBusiness = await createTestBusiness();
|
||||
try {
|
||||
const image = await addGalleryImage(prisma, { businessId: otherBusiness.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/other.png" });
|
||||
|
||||
const updateResult = await updateGalleryImage(prisma, { id: image.id, businessId: business.businessId, caption: "hijacked" });
|
||||
expect(updateResult.success).toBe(false);
|
||||
|
||||
const deleteResult = await deleteGalleryImage(prisma, { id: image.id, businessId: business.businessId });
|
||||
expect(deleteResult.success).toBe(false);
|
||||
|
||||
const stillThere = await prisma.businessGalleryImage.findUnique({ where: { id: image.id } });
|
||||
expect(stillThere).not.toBeNull();
|
||||
expect(stillThere?.caption).not.toBe("hijacked");
|
||||
} finally {
|
||||
await cleanupTestBusiness(otherBusiness);
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes an image it owns", async () => {
|
||||
const image = await addGalleryImage(prisma, { businessId: business.businessId, placement: GalleryPlacement.PORTFOLIO, imageUrl: "/media/a.png" });
|
||||
const result = await deleteGalleryImage(prisma, { id: image.id, businessId: business.businessId });
|
||||
expect(result.success).toBe(true);
|
||||
expect(await prisma.businessGalleryImage.findUnique({ where: { id: image.id } })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { GalleryPlacement } from "@/generated/prisma/enums";
|
||||
|
||||
/**
|
||||
* Owner-managed image galleries — one model/service backs both the Home
|
||||
* page hero carousel (placement: HERO) and the Portfolio tab (placement:
|
||||
* PORTFOLIO) rather than two near-duplicate "gallery" features. See
|
||||
* docs/phase-9-summary.md, "Home Screen Redesign".
|
||||
*/
|
||||
|
||||
export interface GalleryImageSummary {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
category: string | null;
|
||||
caption: string | null;
|
||||
displayOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export async function listGalleryImages(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; placement: GalleryPlacement; activeOnly?: boolean },
|
||||
): Promise<GalleryImageSummary[]> {
|
||||
const images = await prisma.businessGalleryImage.findMany({
|
||||
where: {
|
||||
businessId: params.businessId,
|
||||
placement: params.placement,
|
||||
...(params.activeOnly ? { isActive: true } : {}),
|
||||
},
|
||||
orderBy: { displayOrder: "asc" },
|
||||
});
|
||||
return images.map((i) => ({
|
||||
id: i.id,
|
||||
imageUrl: i.imageUrl,
|
||||
category: i.category,
|
||||
caption: i.caption,
|
||||
displayOrder: i.displayOrder,
|
||||
isActive: i.isActive,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function addGalleryImage(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; placement: GalleryPlacement; imageUrl: string; mediaAssetId?: string; category?: string },
|
||||
): Promise<GalleryImageSummary> {
|
||||
const maxOrder = await prisma.businessGalleryImage.aggregate({
|
||||
where: { businessId: params.businessId, placement: params.placement },
|
||||
_max: { displayOrder: true },
|
||||
});
|
||||
|
||||
const image = await prisma.businessGalleryImage.create({
|
||||
data: {
|
||||
businessId: params.businessId,
|
||||
placement: params.placement,
|
||||
imageUrl: params.imageUrl,
|
||||
mediaAssetId: params.mediaAssetId,
|
||||
category: params.category?.trim() || null,
|
||||
displayOrder: (maxOrder._max.displayOrder ?? -1) + 1,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: image.id,
|
||||
imageUrl: image.imageUrl,
|
||||
category: image.category,
|
||||
caption: image.caption,
|
||||
displayOrder: image.displayOrder,
|
||||
isActive: image.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateGalleryImage(
|
||||
prisma: PrismaClient,
|
||||
params: { id: string; businessId: string; category?: string | null; caption?: string | null; isActive?: boolean },
|
||||
): Promise<{ success: boolean }> {
|
||||
const result = await prisma.businessGalleryImage.updateMany({
|
||||
where: { id: params.id, businessId: params.businessId },
|
||||
data: {
|
||||
...(params.category !== undefined ? { category: params.category?.trim() || null } : {}),
|
||||
...(params.caption !== undefined ? { caption: params.caption?.trim() || null } : {}),
|
||||
...(params.isActive !== undefined ? { isActive: params.isActive } : {}),
|
||||
},
|
||||
});
|
||||
return { success: result.count > 0 };
|
||||
}
|
||||
|
||||
export async function deleteGalleryImage(
|
||||
prisma: PrismaClient,
|
||||
params: { id: string; businessId: string },
|
||||
): Promise<{ success: boolean }> {
|
||||
const result = await prisma.businessGalleryImage.deleteMany({ where: { id: params.id, businessId: params.businessId } });
|
||||
return { success: result.count > 0 };
|
||||
}
|
||||
|
||||
/** Full replacement of display order — same "send the whole ordered id list" pattern as reorderCategories/reorderServices in service-catalogue.service.ts. */
|
||||
export async function reorderGalleryImages(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; placement: GalleryPlacement; orderedIds: string[] },
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(
|
||||
params.orderedIds.map((id, index) =>
|
||||
prisma.businessGalleryImage.updateMany({
|
||||
where: { id, businessId: params.businessId, placement: params.placement },
|
||||
data: { displayOrder: index },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export interface LoyaltySummary {
|
||||
rewardLabel: string | null;
|
||||
stampIcon: string | null;
|
||||
cardBackgroundColor: string | null;
|
||||
cardBackgroundImageUrl: 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). */
|
||||
@@ -89,6 +90,7 @@ export async function getLoyaltySummary(
|
||||
rewardLabel: programme.rewardLabel,
|
||||
stampIcon: programme.stampIcon,
|
||||
cardBackgroundColor: programme.cardBackgroundColor,
|
||||
cardBackgroundImageUrl: programme.cardBackgroundImageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createMediaAsset, deleteMediaAsset, listMediaAssets } from "@/services/media/media.service";
|
||||
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
describe("Media Manager service (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
it("creates a media asset and lists it back for its own business", async () => {
|
||||
const asset = await createMediaAsset(prisma, {
|
||||
businessId: business.businessId,
|
||||
url: "/media/branding/test-1.png",
|
||||
originalFilename: "logo.png",
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 12_345,
|
||||
width: 512,
|
||||
height: 512,
|
||||
category: "branding",
|
||||
uploadedByUserId: null,
|
||||
});
|
||||
|
||||
expect(asset.url).toBe("/media/branding/test-1.png");
|
||||
|
||||
const listed = await listMediaAssets(prisma, { businessId: business.businessId });
|
||||
expect(listed.map((a) => a.id)).toContain(asset.id);
|
||||
});
|
||||
|
||||
it("filters by category and by filename search", async () => {
|
||||
await createMediaAsset(prisma, {
|
||||
businessId: business.businessId,
|
||||
url: "/media/branding/logo.png",
|
||||
originalFilename: "shopfront-logo.png",
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 100,
|
||||
width: null,
|
||||
height: null,
|
||||
category: "branding",
|
||||
uploadedByUserId: null,
|
||||
});
|
||||
await createMediaAsset(prisma, {
|
||||
businessId: business.businessId,
|
||||
url: "/media/portfolio/haircut.jpg",
|
||||
originalFilename: "fresh-fade.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
sizeBytes: 200,
|
||||
width: null,
|
||||
height: null,
|
||||
category: "portfolio",
|
||||
uploadedByUserId: null,
|
||||
});
|
||||
|
||||
const brandingOnly = await listMediaAssets(prisma, { businessId: business.businessId, category: "branding" });
|
||||
expect(brandingOnly).toHaveLength(1);
|
||||
expect(brandingOnly[0]?.category).toBe("branding");
|
||||
|
||||
const searched = await listMediaAssets(prisma, { businessId: business.businessId, q: "fade" });
|
||||
expect(searched).toHaveLength(1);
|
||||
expect(searched[0]?.originalFilename).toBe("fresh-fade.jpg");
|
||||
});
|
||||
|
||||
it("never lets one business delete another business's asset", async () => {
|
||||
const otherBusiness = await createTestBusiness();
|
||||
try {
|
||||
const asset = await createMediaAsset(prisma, {
|
||||
businessId: otherBusiness.businessId,
|
||||
url: "/media/branding/other.png",
|
||||
originalFilename: null,
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 10,
|
||||
width: null,
|
||||
height: null,
|
||||
category: "branding",
|
||||
uploadedByUserId: null,
|
||||
});
|
||||
|
||||
const result = await deleteMediaAsset(prisma, { id: asset.id, businessId: business.businessId });
|
||||
expect(result.success).toBe(false);
|
||||
|
||||
const stillExists = await prisma.mediaAsset.findUnique({ where: { id: asset.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
} finally {
|
||||
await cleanupTestBusiness(otherBusiness);
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes an asset it owns", async () => {
|
||||
const asset = await createMediaAsset(prisma, {
|
||||
businessId: business.businessId,
|
||||
url: "/media/branding/to-delete.png",
|
||||
originalFilename: null,
|
||||
mimeType: "image/png",
|
||||
sizeBytes: 10,
|
||||
width: null,
|
||||
height: null,
|
||||
category: "branding",
|
||||
uploadedByUserId: null,
|
||||
});
|
||||
|
||||
const result = await deleteMediaAsset(prisma, { id: asset.id, businessId: business.businessId });
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const gone = await prisma.mediaAsset.findUnique({ where: { id: asset.id } });
|
||||
expect(gone).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { deleteMediaFile } from "@/lib/media/media-storage";
|
||||
|
||||
/**
|
||||
* The Media Manager's library — see docs/phase-9-summary.md. Backs every
|
||||
* image field app-wide (branding, loyalty card, staff/service photos,
|
||||
* gallery/portfolio) through one reusable picker rather than each
|
||||
* settings page implementing its own uploader.
|
||||
*/
|
||||
export interface MediaAssetSummary {
|
||||
id: string;
|
||||
url: string;
|
||||
originalFilename: string | null;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
category: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface MediaAssetRow {
|
||||
id: string;
|
||||
url: string;
|
||||
originalFilename: string | null;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
category: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function toSummary(asset: MediaAssetRow): MediaAssetSummary {
|
||||
return {
|
||||
id: asset.id,
|
||||
url: asset.url,
|
||||
originalFilename: asset.originalFilename,
|
||||
mimeType: asset.mimeType,
|
||||
sizeBytes: asset.sizeBytes,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
category: asset.category,
|
||||
createdAt: asset.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listMediaAssets(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; category?: string; q?: string },
|
||||
): Promise<MediaAssetSummary[]> {
|
||||
const assets = await prisma.mediaAsset.findMany({
|
||||
where: {
|
||||
businessId: params.businessId,
|
||||
...(params.category ? { category: params.category } : {}),
|
||||
...(params.q ? { originalFilename: { contains: params.q } } : {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return assets.map(toSummary);
|
||||
}
|
||||
|
||||
export async function createMediaAsset(
|
||||
prisma: PrismaClient,
|
||||
params: {
|
||||
businessId: string;
|
||||
url: string;
|
||||
originalFilename: string | null;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
category: string | null;
|
||||
uploadedByUserId: string | null;
|
||||
},
|
||||
): Promise<MediaAssetSummary> {
|
||||
const asset = await prisma.mediaAsset.create({ data: params });
|
||||
return toSummary(asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scoped to businessId so one business can never delete another's asset —
|
||||
* not a real risk in v1's single-business deployment, but a free
|
||||
* guarantee matching the rest of the schema's tenancy discipline.
|
||||
*/
|
||||
export async function deleteMediaAsset(
|
||||
prisma: PrismaClient,
|
||||
params: { id: string; businessId: string },
|
||||
): Promise<{ success: boolean }> {
|
||||
const asset = await prisma.mediaAsset.findFirst({ where: { id: params.id, businessId: params.businessId } });
|
||||
if (!asset) return { success: false };
|
||||
await prisma.mediaAsset.delete({ where: { id: asset.id } });
|
||||
await deleteMediaFile(asset.url);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { syncGoogleReviewsForAllBusinesses, syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
|
||||
import { DbReviewsProvider } from "@/services/reviews/reviews.service";
|
||||
import { MockReviewsProvider } from "@/services/reviews/mock-reviews-provider";
|
||||
import { setReviewHidden } from "@/services/reviews/review-management.service";
|
||||
import { cleanupTestBusiness, createTestBusiness, prisma } from "@/services/scheduling/__tests__/fixtures";
|
||||
import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
|
||||
|
||||
describe("Google Reviews sync + DB-backed provider (integration)", () => {
|
||||
let business: TestBusiness;
|
||||
|
||||
beforeEach(async () => {
|
||||
business = await createTestBusiness();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestBusiness(business);
|
||||
});
|
||||
|
||||
it("syncs reviews and the place-level aggregate rating, idempotently on re-sync", async () => {
|
||||
const mock = new MockReviewsProvider();
|
||||
const first = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||
expect(first.synced).toBe(2);
|
||||
|
||||
const businessRow = await prisma.business.findUniqueOrThrow({ where: { id: business.businessId } });
|
||||
expect(Number(businessRow.googleRating)).toBeCloseTo(4.7);
|
||||
expect(businessRow.googleRatingCount).toBe(128);
|
||||
|
||||
const reviews = await prisma.review.findMany({ where: { businessId: business.businessId } });
|
||||
expect(reviews).toHaveLength(2);
|
||||
|
||||
// Re-sync with the same data — upsert, not duplicate.
|
||||
const second = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||
expect(second.synced).toBe(2);
|
||||
const reviewsAfterResync = await prisma.review.findMany({ where: { businessId: business.businessId } });
|
||||
expect(reviewsAfterResync).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("only syncs businesses with a configured googlePlaceId", async () => {
|
||||
const otherBusiness = await createTestBusiness();
|
||||
try {
|
||||
await prisma.business.update({ where: { id: business.businessId }, data: { googlePlaceId: "place-configured" } });
|
||||
// otherBusiness deliberately left without a googlePlaceId.
|
||||
|
||||
const mock = new MockReviewsProvider();
|
||||
const results = await syncGoogleReviewsForAllBusinesses(prisma, mock);
|
||||
|
||||
expect(results.map((r) => r.businessId)).toContain(business.businessId);
|
||||
expect(results.map((r) => r.businessId)).not.toContain(otherBusiness.businessId);
|
||||
} finally {
|
||||
await cleanupTestBusiness(otherBusiness);
|
||||
}
|
||||
});
|
||||
|
||||
it("records a provider error without throwing", async () => {
|
||||
const failingProvider = { fetchPlaceReviews: async () => { throw new Error("boom"); } };
|
||||
const result = await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, failingProvider);
|
||||
expect(result.error).toBe("boom");
|
||||
expect(result.synced).toBe(0);
|
||||
});
|
||||
|
||||
describe("DbReviewsProvider (customer-facing reads)", () => {
|
||||
it("falls back to demo data when no reviews have ever been synced", async () => {
|
||||
const provider = new DbReviewsProvider(prisma);
|
||||
const summary = await provider.getBusinessReviews(business.businessId);
|
||||
expect(summary.isDemoData).toBe(true);
|
||||
});
|
||||
|
||||
it("returns real synced reviews once available, honoring isHidden", async () => {
|
||||
const mock = new MockReviewsProvider();
|
||||
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||
|
||||
const provider = new DbReviewsProvider(prisma);
|
||||
const before = await provider.getBusinessReviews(business.businessId);
|
||||
expect(before.isDemoData).toBe(false);
|
||||
expect(before.reviews).toHaveLength(2);
|
||||
|
||||
const [firstReview] = await prisma.review.findMany({ where: { businessId: business.businessId } });
|
||||
await setReviewHidden(prisma, { reviewId: firstReview!.id, businessId: business.businessId, isHidden: true });
|
||||
|
||||
const after = await provider.getBusinessReviews(business.businessId);
|
||||
expect(after.reviews).toHaveLength(1);
|
||||
expect(after.reviews.some((r) => r.id === firstReview!.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("honors reviewsMinRating and reviewsExcludeRatingOnly from BusinessSettings", async () => {
|
||||
const mock = new MockReviewsProvider();
|
||||
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||
// MockReviewsProvider's sample: one 5-star review with text, one 3-star review with no text.
|
||||
|
||||
await prisma.businessSettings.upsert({
|
||||
where: { businessId: business.businessId },
|
||||
create: { businessId: business.businessId, reviewsMinRating: 4 },
|
||||
update: { reviewsMinRating: 4 },
|
||||
});
|
||||
|
||||
const provider = new DbReviewsProvider(prisma);
|
||||
const minRatingFiltered = await provider.getBusinessReviews(business.businessId);
|
||||
expect(minRatingFiltered.reviews.every((r) => r.rating >= 4)).toBe(true);
|
||||
expect(minRatingFiltered.reviews).toHaveLength(1);
|
||||
|
||||
await prisma.businessSettings.update({
|
||||
where: { businessId: business.businessId },
|
||||
data: { reviewsMinRating: null, reviewsExcludeRatingOnly: true },
|
||||
});
|
||||
|
||||
const noTextExcluded = await provider.getBusinessReviews(business.businessId);
|
||||
expect(noTextExcluded.reviews.every((r) => r.text.trim().length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("never lets one business's reviews leak into another's summary", async () => {
|
||||
const otherBusiness = await createTestBusiness();
|
||||
try {
|
||||
const mock = new MockReviewsProvider();
|
||||
await syncGoogleReviewsForBusiness(prisma, { businessId: business.businessId, placeId: "place-1" }, mock);
|
||||
|
||||
const provider = new DbReviewsProvider(prisma);
|
||||
const otherSummary = await provider.getBusinessReviews(otherBusiness.businessId);
|
||||
expect(otherSummary.isDemoData).toBe(true);
|
||||
} finally {
|
||||
await cleanupTestBusiness(otherBusiness);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,16 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createDemoReviewsProvider, getBusinessReviews, setReviewsProvider } from "../reviews.service";
|
||||
import type { BusinessReviewSummary, ReviewsProvider } from "../reviews.service";
|
||||
|
||||
describe("reviews.service", () => {
|
||||
// The module has no eager default provider (see reviews.service.ts's
|
||||
// header — it lazily resolves a real, DB-backed provider on first use
|
||||
// otherwise, which would need a live DATABASE_URL). Every test here
|
||||
// explicitly opts into the demo provider so none of them ever touch it.
|
||||
beforeEach(() => {
|
||||
setReviewsProvider(createDemoReviewsProvider());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setReviewsProvider(createDemoReviewsProvider());
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Google Places integration for the Google Reviews feature (Phase 9) —
|
||||
* mirrors src/services/calendar/google-calendar.provider.ts's own
|
||||
* pattern deliberately: a small documented-REST-contract interface, a
|
||||
* plain-`fetch` real implementation, no SDK dependency, and an injectable
|
||||
* mock for tests (see mock-reviews-provider.ts). Never called from a
|
||||
* customer-facing request — only from review-sync.service.ts, itself only
|
||||
* reachable from the /api/cron/sync-google-reviews route.
|
||||
*
|
||||
* Uses the legacy Place Details endpoint (not the newer Places API v1) —
|
||||
* same "documented REST contract, no SDK" choice as the calendar
|
||||
* provider, and it returns exactly the fields this needs (rating,
|
||||
* user_ratings_total, reviews) in one call.
|
||||
*
|
||||
* Known Google-side limitation, not fixable from this codebase: Place
|
||||
* Details only ever returns up to 5 "most relevant" individual reviews
|
||||
* per call, regardless of how many reviews the place actually has. The
|
||||
* place-level aggregate rating/count (googleRating/googleRatingCount) is
|
||||
* accurate; the individual review list is always a small sample.
|
||||
*/
|
||||
|
||||
export interface RawGoogleReview {
|
||||
/**
|
||||
* Google's legacy API has no stable per-review id — derived from
|
||||
* author name + the review's own unix timestamp so a re-sync upserts
|
||||
* the same row instead of duplicating it. Two reviews from the exact
|
||||
* same author in the exact same second would collide; considered
|
||||
* acceptable given Google's own API doesn't expose anything better.
|
||||
*/
|
||||
externalId: string;
|
||||
authorName: string;
|
||||
authorPhotoUrl: string | null;
|
||||
rating: number;
|
||||
text: string | null;
|
||||
relativeTimeDescription: string | null;
|
||||
publishedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface GooglePlaceReviewsResult {
|
||||
reviews: RawGoogleReview[];
|
||||
/** The place's own aggregate rating/count — distinct from (and more accurate than) averaging just the review sample above. */
|
||||
rating: number | null;
|
||||
userRatingsTotal: number | null;
|
||||
}
|
||||
|
||||
export interface ReviewsFetchProvider {
|
||||
fetchPlaceReviews(placeId: string): Promise<GooglePlaceReviewsResult>;
|
||||
}
|
||||
|
||||
export class GooglePlacesProviderError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "GooglePlacesProviderError";
|
||||
}
|
||||
}
|
||||
|
||||
interface LegacyPlaceDetailsReview {
|
||||
author_name?: string;
|
||||
profile_photo_url?: string;
|
||||
rating?: number;
|
||||
text?: string;
|
||||
relative_time_description?: string;
|
||||
time?: number;
|
||||
}
|
||||
|
||||
interface LegacyPlaceDetailsResponse {
|
||||
status: string;
|
||||
error_message?: string;
|
||||
result?: {
|
||||
rating?: number;
|
||||
user_ratings_total?: number;
|
||||
reviews?: LegacyPlaceDetailsReview[];
|
||||
};
|
||||
}
|
||||
|
||||
export class GooglePlacesReviewsProvider implements ReviewsFetchProvider {
|
||||
async fetchPlaceReviews(placeId: string): Promise<GooglePlaceReviewsResult> {
|
||||
const apiKey = process.env.GOOGLE_PLACES_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new GooglePlacesProviderError("GOOGLE_PLACES_API_KEY is not configured on this deployment.");
|
||||
}
|
||||
|
||||
const url = new URL("https://maps.googleapis.com/maps/api/place/details/json");
|
||||
url.searchParams.set("place_id", placeId);
|
||||
url.searchParams.set("fields", "rating,user_ratings_total,reviews");
|
||||
url.searchParams.set("key", apiKey);
|
||||
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new GooglePlacesProviderError(`Google Places API request failed with HTTP ${response.status}.`);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as LegacyPlaceDetailsResponse;
|
||||
if (body.status !== "OK") {
|
||||
throw new GooglePlacesProviderError(
|
||||
`Google Places API returned status ${body.status}${body.error_message ? `: ${body.error_message}` : ""}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const reviews: RawGoogleReview[] = (body.result?.reviews ?? []).map((r) => ({
|
||||
externalId: `${r.author_name ?? "anonymous"}-${r.time ?? 0}`,
|
||||
authorName: r.author_name ?? "Anonymous",
|
||||
authorPhotoUrl: r.profile_photo_url ?? null,
|
||||
rating: typeof r.rating === "number" ? r.rating : 0,
|
||||
text: r.text || null,
|
||||
relativeTimeDescription: r.relative_time_description ?? null,
|
||||
publishedAt: typeof r.time === "number" ? new Date(r.time * 1000) : null,
|
||||
}));
|
||||
|
||||
return {
|
||||
reviews,
|
||||
rating: typeof body.result?.rating === "number" ? body.result.rating : null,
|
||||
userRatingsTotal: typeof body.result?.user_ratings_total === "number" ? body.result.user_ratings_total : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { GooglePlaceReviewsResult, ReviewsFetchProvider } from "./google-places.provider";
|
||||
|
||||
/**
|
||||
* In-memory `ReviewsFetchProvider` for tests — never makes a real network
|
||||
* call, same rationale as MockCalendarProvider (src/services/calendar).
|
||||
* `result` is settable per-test; defaults to a small realistic sample.
|
||||
*/
|
||||
export class MockReviewsProvider implements ReviewsFetchProvider {
|
||||
result: GooglePlaceReviewsResult = {
|
||||
rating: 4.7,
|
||||
userRatingsTotal: 128,
|
||||
reviews: [
|
||||
{
|
||||
externalId: "mock-author-1-1700000000",
|
||||
authorName: "Mock Author One",
|
||||
authorPhotoUrl: null,
|
||||
rating: 5,
|
||||
text: "Great service, highly recommend.",
|
||||
relativeTimeDescription: "a week ago",
|
||||
publishedAt: new Date("2024-01-01T00:00:00Z"),
|
||||
},
|
||||
{
|
||||
externalId: "mock-author-2-1700000100",
|
||||
authorName: "Mock Author Two",
|
||||
authorPhotoUrl: null,
|
||||
rating: 3,
|
||||
text: null,
|
||||
relativeTimeDescription: "a month ago",
|
||||
publishedAt: new Date("2024-01-02T00:00:00Z"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
fetchCallCount = 0;
|
||||
|
||||
async fetchPlaceReviews(): Promise<GooglePlaceReviewsResult> {
|
||||
this.fetchCallCount += 1;
|
||||
return this.result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Owner-side review moderation — distinct from reviews.service.ts (the
|
||||
* customer-facing, policy-filtered read). This always returns everything,
|
||||
* hidden or not, so the owner can see what they're hiding/unhiding.
|
||||
*/
|
||||
export interface OwnerReviewItem {
|
||||
id: string;
|
||||
authorName: string;
|
||||
authorPhotoUrl: string | null;
|
||||
rating: number;
|
||||
text: string | null;
|
||||
relativeTimeDescription: string | null;
|
||||
publishedAt: Date | null;
|
||||
isHidden: boolean;
|
||||
fetchedAt: Date;
|
||||
}
|
||||
|
||||
export async function listReviewsForOwner(prisma: PrismaClient, businessId: string): Promise<OwnerReviewItem[]> {
|
||||
const reviews = await prisma.review.findMany({
|
||||
where: { businessId },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
});
|
||||
return reviews.map((r) => ({
|
||||
id: r.id,
|
||||
authorName: r.authorName,
|
||||
authorPhotoUrl: r.authorPhotoUrl,
|
||||
rating: r.rating,
|
||||
text: r.text,
|
||||
relativeTimeDescription: r.relativeTimeDescription,
|
||||
publishedAt: r.publishedAt,
|
||||
isHidden: r.isHidden,
|
||||
fetchedAt: r.fetchedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function setReviewHidden(
|
||||
prisma: PrismaClient,
|
||||
params: { reviewId: string; businessId: string; isHidden: boolean },
|
||||
): Promise<{ success: boolean }> {
|
||||
const result = await prisma.review.updateMany({
|
||||
where: { id: params.reviewId, businessId: params.businessId },
|
||||
data: { isHidden: params.isHidden },
|
||||
});
|
||||
return { success: result.count > 0 };
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { GooglePlacesReviewsProvider } from "./google-places.provider";
|
||||
import type { ReviewsFetchProvider } from "./google-places.provider";
|
||||
|
||||
/**
|
||||
* The 24h scheduled sync (see /api/cron/sync-google-reviews) — the only
|
||||
* place that ever calls the Google Places API. Everything customer-facing
|
||||
* reads the Review table instead (see reviews.service.ts's DbReviewsProvider),
|
||||
* never Google directly, per the brief's explicit requirement.
|
||||
*/
|
||||
|
||||
function getDefaultProvider(): ReviewsFetchProvider {
|
||||
return new GooglePlacesReviewsProvider();
|
||||
}
|
||||
|
||||
export interface BusinessReviewSyncResult {
|
||||
businessId: string;
|
||||
synced: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** One business's sync — upserts every review Google returned (idempotent via the businessId+externalId unique constraint) and refreshes the place-level aggregate rating/count. */
|
||||
export async function syncGoogleReviewsForBusiness(
|
||||
prisma: PrismaClient,
|
||||
params: { businessId: string; placeId: string },
|
||||
provider: ReviewsFetchProvider = getDefaultProvider(),
|
||||
): Promise<BusinessReviewSyncResult> {
|
||||
try {
|
||||
const result = await provider.fetchPlaceReviews(params.placeId);
|
||||
const fetchedAt = new Date();
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.business.update({
|
||||
where: { id: params.businessId },
|
||||
data: { googleRating: result.rating, googleRatingCount: result.userRatingsTotal },
|
||||
}),
|
||||
...result.reviews.map((review) =>
|
||||
prisma.review.upsert({
|
||||
where: { businessId_externalId: { businessId: params.businessId, externalId: review.externalId } },
|
||||
create: {
|
||||
businessId: params.businessId,
|
||||
externalId: review.externalId,
|
||||
authorName: review.authorName,
|
||||
authorPhotoUrl: review.authorPhotoUrl,
|
||||
rating: review.rating,
|
||||
text: review.text,
|
||||
relativeTimeDescription: review.relativeTimeDescription,
|
||||
publishedAt: review.publishedAt,
|
||||
fetchedAt,
|
||||
},
|
||||
update: {
|
||||
authorName: review.authorName,
|
||||
authorPhotoUrl: review.authorPhotoUrl,
|
||||
rating: review.rating,
|
||||
text: review.text,
|
||||
relativeTimeDescription: review.relativeTimeDescription,
|
||||
publishedAt: review.publishedAt,
|
||||
fetchedAt,
|
||||
},
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
return { businessId: params.businessId, synced: result.reviews.length };
|
||||
} catch (error) {
|
||||
return { businessId: params.businessId, synced: 0, error: error instanceof Error ? error.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Every business with a configured Google Place ID — businesses without one are silently skipped (review sync is opt-in, see Business.googlePlaceId). */
|
||||
export async function syncGoogleReviewsForAllBusinesses(
|
||||
prisma: PrismaClient,
|
||||
provider: ReviewsFetchProvider = getDefaultProvider(),
|
||||
): Promise<BusinessReviewSyncResult[]> {
|
||||
const businesses = await prisma.business.findMany({
|
||||
where: { googlePlaceId: { not: null } },
|
||||
select: { id: true, googlePlaceId: true },
|
||||
});
|
||||
|
||||
const results: BusinessReviewSyncResult[] = [];
|
||||
for (const business of businesses) {
|
||||
if (!business.googlePlaceId) continue;
|
||||
results.push(await syncGoogleReviewsForBusiness(prisma, { businessId: business.id, placeId: business.googlePlaceId }, provider));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Reviews behind a provider abstraction, per the Phase 4 brief — a real
|
||||
* Google Places/Business Profile integration can implement this same
|
||||
* interface later without any caller (Customer Home, etc.) changing.
|
||||
* Backed by clearly-labelled demo data for now; no schema/DB involvement,
|
||||
* per the Phase 1 plan's recommendation to treat reviews as an external,
|
||||
* cached read rather than a first-party entity.
|
||||
* Reviews behind a provider abstraction, per the original Phase 4 brief —
|
||||
* "a real Google Places/Business Profile integration can implement this
|
||||
* same interface later without any caller (Customer Home, etc.)
|
||||
* changing." Phase 9 is that moment: DbReviewsProvider below reads the
|
||||
* `Review` table (populated by the 24h sync job — see
|
||||
* review-sync.service.ts) instead of calling Google directly, honoring
|
||||
* the owner's visibility policy (Review.isHidden,
|
||||
* BusinessSettings.reviewsMinRating/reviewsExcludeRatingOnly). Falls back
|
||||
* to demo data only when a business has never had any reviews synced at
|
||||
* all, so a fresh install doesn't show an empty Reviews tab.
|
||||
*/
|
||||
|
||||
export interface ReviewItem {
|
||||
@@ -12,7 +18,7 @@ export interface ReviewItem {
|
||||
authorName: string;
|
||||
rating: number; // 1–5
|
||||
text: string;
|
||||
relativeDate: string; // pre-formatted, e.g. "2 weeks ago" — a real provider would supply this directly
|
||||
relativeDate: string; // pre-formatted, e.g. "2 weeks ago"
|
||||
}
|
||||
|
||||
export interface BusinessReviewSummary {
|
||||
@@ -51,7 +57,7 @@ const DEMO_REVIEWS: ReviewItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
class DemoReviewsProvider implements ReviewsProvider {
|
||||
export class DemoReviewsProvider implements ReviewsProvider {
|
||||
async getBusinessReviews(_businessId: string): Promise<BusinessReviewSummary> {
|
||||
const totalReviews = 1142; // matches the figure shown in templates/15
|
||||
const averageRating = 5.0;
|
||||
@@ -64,13 +70,82 @@ export function createDemoReviewsProvider(): ReviewsProvider {
|
||||
return new DemoReviewsProvider();
|
||||
}
|
||||
|
||||
let provider: ReviewsProvider = new DemoReviewsProvider();
|
||||
function averageOf(reviews: { rating: number }[]): number {
|
||||
if (reviews.length === 0) return 0;
|
||||
return reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length;
|
||||
}
|
||||
|
||||
/** Swap the provider (e.g. in a future Google-backed implementation, or a test double) without touching any caller. */
|
||||
/** The real, DB-backed provider — see this file's header. Never calls Google; only ever reads what review-sync.service.ts already wrote. */
|
||||
export class DbReviewsProvider implements ReviewsProvider {
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
|
||||
async getBusinessReviews(businessId: string): Promise<BusinessReviewSummary> {
|
||||
const totalSynced = await this.prisma.review.count({ where: { businessId } });
|
||||
if (totalSynced === 0) {
|
||||
return new DemoReviewsProvider().getBusinessReviews(businessId);
|
||||
}
|
||||
|
||||
const [business, settings] = await Promise.all([
|
||||
this.prisma.business.findUnique({ where: { id: businessId }, select: { googleRating: true, googleRatingCount: true } }),
|
||||
this.prisma.businessSettings.findUnique({
|
||||
where: { businessId },
|
||||
select: { reviewsMinRating: true, reviewsExcludeRatingOnly: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const visibleReviews = await this.prisma.review.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
isHidden: false,
|
||||
...(settings?.reviewsMinRating ? { rating: { gte: settings.reviewsMinRating } } : {}),
|
||||
...(settings?.reviewsExcludeRatingOnly ? { text: { not: null } } : {}),
|
||||
},
|
||||
orderBy: { publishedAt: "desc" },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
return {
|
||||
// The place-level aggregate is more accurate than averaging the
|
||||
// (Google-capped-at-5) sample — see google-places.provider.ts's
|
||||
// header for why the two are tracked separately.
|
||||
averageRating: business?.googleRating ? Number(business.googleRating) : averageOf(visibleReviews),
|
||||
totalReviews: business?.googleRatingCount ?? visibleReviews.length,
|
||||
reviews: visibleReviews.map((r) => ({
|
||||
id: r.id,
|
||||
authorName: r.authorName,
|
||||
rating: r.rating,
|
||||
text: r.text ?? "",
|
||||
relativeDate: r.relativeTimeDescription ?? "",
|
||||
})),
|
||||
isDemoData: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `null` = no explicit override; resolved lazily to a real DbReviewsProvider
|
||||
* on first use. Deliberately NOT constructed eagerly at module load — this
|
||||
* file must stay importable from a pure unit-test context with no
|
||||
* DATABASE_URL (see reviews.service.test.ts), and src/lib/db/prisma.ts
|
||||
* throws at import time if that's unset.
|
||||
*/
|
||||
let provider: ReviewsProvider | null = null;
|
||||
let cachedDefaultProvider: ReviewsProvider | null = null;
|
||||
|
||||
/** Swap the provider (e.g. back to demo data, or a test double) without touching any caller. */
|
||||
export function setReviewsProvider(next: ReviewsProvider): void {
|
||||
provider = next;
|
||||
}
|
||||
|
||||
async function resolveDefaultProvider(): Promise<ReviewsProvider> {
|
||||
if (!cachedDefaultProvider) {
|
||||
const { prisma } = await import("@/lib/db/prisma");
|
||||
cachedDefaultProvider = new DbReviewsProvider(prisma);
|
||||
}
|
||||
return cachedDefaultProvider;
|
||||
}
|
||||
|
||||
export async function getBusinessReviews(businessId: string): Promise<BusinessReviewSummary> {
|
||||
return provider.getBusinessReviews(businessId);
|
||||
const active = provider ?? (await resolveDefaultProvider());
|
||||
return active.getBusinessReviews(businessId);
|
||||
}
|
||||
@@ -41,6 +41,10 @@ export interface AvailableService {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
name: string;
|
||||
/** Collapses in the UI when null/empty — see ServiceCard and ServiceSelector. */
|
||||
description: string | null;
|
||||
/** Set via the Media Manager — see src/features/media/components/media-picker.tsx. */
|
||||
imageUrl: string | null;
|
||||
durationMinutes: number;
|
||||
price: string; // Decimal serialized to string — currency formatting is a presentation concern, not this layer's
|
||||
bufferBeforeMinutes: number;
|
||||
@@ -66,6 +70,8 @@ export async function getAvailableServices(
|
||||
id: s.id,
|
||||
categoryId: s.categoryId,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
imageUrl: s.imageUrl,
|
||||
durationMinutes: s.durationMinutes,
|
||||
price: s.price.toString(),
|
||||
bufferBeforeMinutes: s.bufferBeforeMinutes,
|
||||
|
||||
@@ -25,6 +25,9 @@ export interface StaffSummary {
|
||||
export interface ServiceSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Collapses in the UI when null/undefined/empty — see ServiceCard. */
|
||||
description?: string | null;
|
||||
imageUrl?: string | null;
|
||||
durationMinutes: number;
|
||||
/** Formatted for display, e.g. "£22.00" — currency formatting is a
|
||||
* presentation concern handled once, upstream of these components. */
|
||||
|
||||
Reference in new issue
Block a user