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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user