Files
NextChair/src/features/owner/components/gallery-manager.tsx
T
Stefan Lazic 9e39908ea7 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).
2026-08-04 17:10:30 +01:00

150 lines
5.6 KiB
TypeScript

"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>
);
}