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:
Stefan Lazic committed 2026-08-04 17:10:30 +01:00
1 parent 77f25c0489
commit 9e39908ea7
79 files changed
+4121 -450

No files matched your search

@@ -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
+79
View File
@@ -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 };
}
+385
View File
@@ -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 &amp; 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>
);
}
+121
View File
@@ -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 } });
}
});
});
});
+9 -3
View File
@@ -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: {
+98
View File
@@ -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 &amp; 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&apos;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"