Fixed an issue where media manager only shows files uploaded in specific sections.

Fixed an issue with icon picker names.

Removed jumbo rounding and a few other style tweaks.
This commit is contained in:
Stefan Lazic committed 2026-08-05 09:25:59 +01:00
1 parent 9e39908ea7
commit d3bbad09c6
74 files changed
+1622 -195

No files matched your search

+52 -1
View File
@@ -9,7 +9,13 @@ import { getAppointmentById } from "@/services/scheduling/appointment.service";
import { validateBookingSlot } from "@/services/scheduling/availability.service";
import { assertCanAccessAppointment, PermissionDeniedError } from "../authorization";
import { getFirstFriendlyMessage } from "../error-messages";
import { saveBarberTimeStepSchema, saveDetailsStepSchema, saveServicesStepSchema, startRebookSchema } from "../schemas";
import {
preselectServiceSchema,
saveBarberTimeStepSchema,
saveDetailsStepSchema,
saveServicesStepSchema,
startRebookSchema,
} from "../schemas";
/**
* Step-by-step booking draft actions. Each one: parses input, calls a
@@ -49,6 +55,51 @@ export async function saveServicesStepAction(input: unknown): Promise<StepAction
redirect("/book/barber-time");
}
/**
* Home's Services tab "Book" button (see BookServiceButton) — adds one
* service to the existing booking draft and sends the customer straight
* to /book with it already selected, per the follow-up brief's "Home
* Service Book Preselection" requirement. Only ever receives a service
* id from the client; everything else (does it exist, is it active, is
* it online-bookable, which business it belongs to) is re-read from the
* database here, never trusted from the caller — same discipline as
* every other draft action in this file.
*
* Reuses the exact same "only clear staff/date/time if the service
* selection actually changed" rule saveServicesStepAction already uses
* above, rather than inventing a second one.
*/
export async function preselectServiceAction(
input: unknown,
): Promise<{ success: false; error: string } | never> {
const parsed = preselectServiceSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: "That service couldn't be selected." };
}
const service = await prisma.service.findUnique({ where: { id: parsed.data.serviceId } });
if (!service || !service.isActive || !service.allowOnlineBooking) {
return { success: false, error: "This service isn't available for online booking right now." };
}
const draft = await readDraft(service.businessId);
const alreadySelected = draft.serviceIds.includes(service.id);
const nextServiceIds = alreadySelected ? draft.serviceIds : [...draft.serviceIds, service.id];
await writeDraft({
...draft,
businessId: service.businessId,
serviceIds: nextServiceIds,
// Adding a genuinely new service can change total duration/eligible
// barbers, invalidating a previously chosen slot — clear it. Re-clicking
// Book for a service that's already selected changes nothing, so leave
// any in-progress barber/date/time alone.
...(alreadySelected ? {} : { staffId: undefined, date: undefined, startAt: undefined }),
});
redirect("/book");
}
export async function saveBarberTimeStepAction(input: unknown): Promise<StepActionResult> {
const parsed = saveBarberTimeStepSchema.safeParse(input);
if (!parsed.success) return { success: false, error: "Please choose a valid time." };
@@ -0,0 +1,39 @@
"use client";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { preselectServiceAction } from "../actions/draft";
/**
* Home's Services tab "Book" button — preselects this service in the
* booking draft (see preselectServiceAction) before sending the customer
* to /book, instead of the plain `<Link href="/book">` it used to be.
* `preselectServiceAction` redirects on success; this only ever has
* something to render when it instead returns a friendly error (an
* inactive/removed service, or one no longer online-bookable).
*/
export function BookServiceButton({ serviceId }: { serviceId: string }) {
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleClick = () => {
setError(null);
startTransition(async () => {
const result = await preselectServiceAction({ serviceId });
if (result && !result.success) setError(result.error);
});
};
return (
<div className="flex flex-col items-end gap-1">
<Button type="button" size="sm" isLoading={isPending} onClick={handleClick}>
Book
</Button>
{error && (
<p role="alert" className="text-danger max-w-[10rem] text-right text-[10px] font-medium">
{error}
</p>
)}
</div>
);
}
@@ -106,7 +106,7 @@ export function DetailsForm(props: {
)}
{isAuthenticated && prefill && (
<div className="rounded-jumbo border-line border bg-white p-5">
<div className="rounded-md border-line border bg-white p-5">
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Booking as</p>
<p className="mt-1 text-sm font-bold">
{prefill.firstName} {prefill.lastName}
@@ -77,7 +77,7 @@ export function ServiceSelector(props: {
{grouped.map((group) => (
<section key={group.id}>
<h2 className="mb-3 text-sm font-bold">{group.name}</h2>
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
<div className="divide-line rounded-md border-line divide-y border bg-white">
{group.services.map((service) => {
const selected = selectedIds.includes(service.id);
return (
+5
View File
@@ -13,6 +13,11 @@ export const getAvailableServicesSchema = z.object({
staffId: z.string().optional(),
});
/** Home's Services tab "Book" button — see preselectServiceAction. Only a service id ever crosses this boundary; price/duration/eligibility are always re-read from the database server-side, never trusted from the client. */
export const preselectServiceSchema = z.object({
serviceId: z.string().min(1),
});
export const getAvailableBarbersSchema = z.object({
businessId: z.string(),
serviceIds: z.array(z.string()).min(1),
@@ -66,7 +66,7 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
if (!state.connected) {
return (
<div className="border-line rounded-jumbo border bg-white p-5">
<div className="border-line rounded-md border bg-white p-5">
<p className="mb-1 text-sm font-bold">Google Calendar</p>
<p className="text-muted mb-4 text-xs">
Sync your confirmed appointments to your own Google Calendar. This connects a Google account separately
@@ -85,7 +85,7 @@ export function CalendarConnectionPanel({ initial, configured }: { initial: Conn
}
return (
<div className="border-line rounded-jumbo border bg-white p-5">
<div className="border-line rounded-md border bg-white p-5">
<p className="mb-1 text-sm font-bold">Google Calendar</p>
<div className="text-muted mb-4 space-y-1 text-xs">
<p>Connected as {state.accountEmail}</p>
+5 -10
View File
@@ -1,13 +1,12 @@
"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 { BookServiceButton } from "@/features/booking/components/book-service-button";
import { cn } from "@/lib/utils/cn";
import type { AvailableService } from "@/services/scheduling/availability.service";
import type { BusinessReviewSummary } from "@/services/reviews/reviews.service";
@@ -40,7 +39,7 @@ export interface HomeTabsProps {
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">
<TabsList className="bg-white border-t border-b border-line sticky top-0 z-30 -mx-6 rounded-none px-6 py-3">
<TabsTrigger value="services">Services</TabsTrigger>
<TabsTrigger value="portfolio">Portfolio</TabsTrigger>
<TabsTrigger value="reviews">Reviews</TabsTrigger>
@@ -165,11 +164,7 @@ function ServicesTab({
durationMinutes: service.durationMinutes,
formattedPrice: `£${Number(service.price).toFixed(2)}`,
}}
action={
<Button asChild size="sm">
<Link href="/book">Book</Link>
</Button>
}
action={<BookServiceButton serviceId={service.id} />}
/>
))}
</Card>
@@ -289,7 +284,7 @@ function DetailsTab({
<div className="space-y-8">
{mapEmbedSrc && (
<section>
<div className="bg-stone aspect-[16/9] w-full overflow-hidden rounded-jumbo">
<div className="bg-stone aspect-[16/9] w-full overflow-hidden rounded-md">
<iframe
src={mapEmbedSrc}
title="Business location map"
@@ -336,7 +331,7 @@ function DetailsTab({
{(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">
<div className="grid grid-cols-3 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
@@ -401,7 +401,7 @@ export function InstallWizard() {
Copy Monday to all days
</button>
</div>
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
<div className="divide-line rounded-md border-line divide-y border bg-white">
{hours.map((day) => (
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-center gap-3">
@@ -427,7 +427,7 @@ export function InstallWizard() {
{step === 6 && (
<div className="space-y-4">
<h2 className="text-xl font-bold">Booking settings</h2>
<div className="rounded-jumbo border-line border bg-white p-5">
<div className="rounded-md border-line border bg-white p-5">
<Switch
checked={bookingRules.allowGuestBooking}
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, allowGuestBooking: checked })}
@@ -467,7 +467,7 @@ export function InstallWizard() {
onChange={(e) => setBookingRules({ ...bookingRules, slotIntervalMinutes: Number(e.target.value) })}
/>
</div>
<div className="rounded-jumbo border-line space-y-3 border bg-white p-5">
<div className="rounded-md border-line space-y-3 border bg-white p-5">
<Switch
checked={bookingRules.customerCanChooseStaff}
onCheckedChange={(checked) => setBookingRules({ ...bookingRules, customerCanChooseStaff: checked })}
@@ -509,7 +509,7 @@ export function InstallWizard() {
</p>
<div className="space-y-3">
{starterServices.map((s) => (
<div key={s.key} className="rounded-jumbo border-line space-y-3 border bg-white p-4">
<div key={s.key} className="rounded-md border-line space-y-3 border bg-white p-4">
<div className="flex items-center justify-between">
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">Service</p>
<button type="button" onClick={() => removeStarter(s.key)} className="text-danger text-xs font-bold uppercase">
@@ -590,7 +590,7 @@ function StepNav({ onBack, onNext, nextDisabled }: { onBack: () => void; onNext:
function ReviewSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-jumbo border-line border bg-white p-4">
<div className="rounded-md border-line border bg-white p-4">
<p className="text-muted text-[10px] font-bold tracking-widest uppercase">{title}</p>
<p className="mt-1 text-sm">{children}</p>
</div>
@@ -20,7 +20,7 @@ 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". */
/** Tags new uploads for this field — e.g. "branding", "loyalty", "staff", "service", "portfolio", "welcome". The Library tab is one shared pool across every field by default; this only narrows it down if "This Field Only" is picked. */
category: string;
}
@@ -29,12 +29,21 @@ export interface MediaManagerModalProps {
* 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).
*
* The Library is a single, business-wide pool: an image uploaded from the
* Gallery manager shows up when picking a logo, and vice versa — "category"
* only tags where an upload came from and lets the "This Field Only" filter
* narrow the view, it never hides an image from other fields by default.
*/
export function MediaManagerModal({ open, onClose, onSelect, category }: MediaManagerModalProps) {
const [tab, setTab] = useState("library");
const [assets, setAssets] = useState<MediaAssetSummary[]>([]);
const [query, setQuery] = useState("");
const [scope, setScope] = useState<"category" | "all">("category");
// Defaults to "all" — the library is one shared pool across every image
// field (logo, cover, gallery, staff/service photos, ...), so an image
// uploaded anywhere should be visible everywhere by default. "category"
// is an opt-in narrowing filter, not the default view.
const [scope, setScope] = useState<"all" | "category">("all");
const [selectedAsset, setSelectedAsset] = useState<MediaAssetSummary | null>(null);
const [externalUrl, setExternalUrl] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -97,8 +106,8 @@ export function MediaManagerModal({ open, onClose, onSelect, category }: MediaMa
)}
<TabsContent value="library">
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1">
<div className="mb-4 space-y-3">
<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"
@@ -108,13 +117,27 @@ export function MediaManagerModal({ open, onClose, onSelect, category }: MediaMa
className="border-line focus:ring-ink w-full rounded-xl border bg-white py-2.5 pr-4 pl-11 text-sm outline-none focus:ring-2"
/>
</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 className="flex gap-2">
{(
[
{ value: "all", label: "All Images" },
{ value: "category", label: "This Field Only" },
] as const
).map((option) => (
<button
key={option.value}
type="button"
onClick={() => setScope(option.value)}
aria-pressed={scope === option.value}
className={cn(
"rounded-full px-4 py-1.5 text-xs font-bold tracking-widest uppercase",
scope === option.value ? "bg-ink text-white" : "border-line text-muted border bg-white",
)}
>
{option.label}
</button>
))}
</div>
</div>
{isPending && assets.length === 0 && !error ? (
@@ -9,7 +9,7 @@ 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". */
/** Tags new uploads with where they came from — e.g. "branding", "loyalty", "staff", "service", "portfolio", "welcome". The library itself is shared across every field; this doesn't hide other fields' images by default (see MediaManagerModal). */
category: string;
hint?: string;
}
@@ -74,7 +74,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
const unread = !notification.readAt;
const content = (
<div
className={`rounded-jumbo border p-4 transition-colors ${
className={`rounded-md border p-4 transition-colors ${
unread ? "border-accent/30 bg-accent/5" : "border-line bg-white"
}`}
>
+35 -7
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 { getActingBusinessContext } from "@/lib/business/get-acting-business-context";
import { writeAuditLog } from "@/lib/audit/write-audit-log";
import { updateBusinessProfile, updateBusinessSettings } from "@/services/business/business.service";
import { setReviewHidden } from "@/services/reviews/review-management.service";
import { syncGoogleReviewsForBusiness } from "@/services/reviews/review-sync.service";
@@ -16,28 +17,42 @@ export interface ReviewsActionResult {
async function requireReviewsManager() {
const session = await auth();
if (!session?.user) return { businessId: null, error: "You must be signed in." } as const;
if (!session?.user) return { businessId: null, userId: null, error: "You must be signed in." } as const;
try {
await assertOwnerPermission(session.user.id, Permission.MANAGE_REVIEWS);
} catch (error) {
if (error instanceof OwnerPermissionDeniedError) return { businessId: null, error: error.message } as const;
if (error instanceof OwnerPermissionDeniedError) return { businessId: null, userId: null, error: error.message } as const;
throw error;
}
const context = await getActingBusinessContext();
if (!context) return { businessId: null, error: "You must be signed in as staff." } as const;
if (!context) return { businessId: null, userId: null, error: "You must be signed in as staff." } as const;
return { businessId: context.businessId, error: null } as const;
return { businessId: context.businessId, userId: context.userId, error: null } as const;
}
/** Reuses updateBusinessProfile/updateBusinessSettings (the same functions Business Details uses) rather than a parallel update path — googlePlaceId lives on Business, the two policy flags on BusinessSettings. */
/**
* Reuses updateBusinessProfile/updateBusinessSettings (the same functions
* Business Details uses) rather than a parallel update path —
* googlePlaceId lives on Business, the two policy flags on
* BusinessSettings. Deliberately no format regex on googlePlaceId beyond
* trimming — Google's own Place ID formats vary (place, postal code,
* plus-code IDs all look different) and this app has no need to validate
* more than "the owner typed something."
*
* Writes an audit log only when the Place ID actually changed — existing
* imported Review rows are deliberately left as-is when it changes (kept
* for history/audit, per docs/phase-10-summary.md's "Google Place ID"
* section); the next sync (scheduled or manual, via syncReviewsNowAction)
* naturally reconciles what's currently associated with the new Place ID.
*/
export async function updateReviewsSettingsAction(input: {
googlePlaceId: string | null;
reviewsMinRating: number | null;
reviewsExcludeRatingOnly: boolean;
}): Promise<ReviewsActionResult> {
const { businessId, error } = await requireReviewsManager();
const { businessId, userId, error } = await requireReviewsManager();
if (!businessId) return { success: false, error: error ?? "Not authorized." };
const business = await prisma.business.findUnique({ where: { id: businessId } });
@@ -45,6 +60,9 @@ export async function updateReviewsSettingsAction(input: {
const location = await prisma.businessLocation.findFirst({ where: { businessId, isPrimary: true } });
if (!location) return { success: false, error: "Business location not found." };
const nextPlaceId = input.googlePlaceId?.trim() || null;
const placeIdChanged = nextPlaceId !== business.googlePlaceId;
const profileResult = await updateBusinessProfile(prisma, {
businessId,
profile: {
@@ -52,7 +70,7 @@ export async function updateReviewsSettingsAction(input: {
timezone: business.timezone,
currency: business.currency,
locale: business.locale,
googlePlaceId: input.googlePlaceId,
googlePlaceId: nextPlaceId,
},
location: {
addressLine1: location.addressLine1,
@@ -70,6 +88,16 @@ export async function updateReviewsSettingsAction(input: {
settings: { reviewsMinRating: input.reviewsMinRating, reviewsExcludeRatingOnly: input.reviewsExcludeRatingOnly },
});
if (placeIdChanged) {
await writeAuditLog(prisma, {
actorUserId: userId,
action: "GOOGLE_PLACE_ID_CHANGED",
entityType: "Business",
entityId: businessId,
metadata: { previousPlaceId: business.googlePlaceId, nextPlaceId },
});
}
return { success: true };
}
@@ -58,7 +58,7 @@ export function AddStaffForm({ roles, services }: AddStaffFormProps) {
if (inviteLink) {
return (
<div className="space-y-4 rounded-jumbo border-line border bg-white p-6">
<div className="space-y-4 rounded-md border-line border bg-white p-6">
<p className="text-sm font-bold">{name} has been added.</p>
<p className="text-muted text-sm">
Email delivery isn&apos;t wired up yet — copy this invite link and send it to them yourself. It lets them
@@ -79,7 +79,7 @@ export function AddStaffForm({ roles, services }: AddStaffFormProps) {
return (
<div className="space-y-4">
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Input label="Name" required value={name} onChange={(e) => setName(e.target.value)} />
<Input label="Email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} />
<Input label="Phone (optional)" type="tel" value={phone} onChange={(e) => setPhone(e.target.value)} />
@@ -95,7 +95,7 @@ export function AddStaffForm({ roles, services }: AddStaffFormProps) {
<Switch label="Visible for online booking" checked={isBookableOnline} onCheckedChange={setIsBookableOnline} />
</div>
<div className="rounded-jumbo border-line border bg-white p-6">
<div className="rounded-md border-line border bg-white p-6">
<p className="mb-3 text-xs font-bold tracking-widest uppercase">Services Offered</p>
<div className="space-y-3">
{services.map((s) => (
@@ -38,7 +38,7 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Booking Window</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Input
label="Minimum notice (minutes)"
hint="How soon before a slot a booking can be made."
@@ -60,7 +60,7 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Cancellation &amp; Rescheduling</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Input
label="Free cancellation window (hours)"
hint="Cancelling later than this may count as a late cancellation."
@@ -81,7 +81,7 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Scheduling</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Select
label="Appointment slot interval"
value={String(form.slotIntervalMinutes)}
@@ -128,7 +128,7 @@ export function BookingRulesForm({ initial }: { initial: BookingRuleInput }) {
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Customer Options</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<div className="rounded-md border-line space-y-5 border bg-white p-6">
<Switch
label="Customers can choose a barber"
description='If off, "Anyone" is assigned automatically.'
@@ -115,7 +115,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Profile</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Input label="Business Name" required value={form.name} onChange={(e) => set("name", e.target.value)} />
<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)} />
@@ -124,7 +124,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
<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">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<MediaPicker
label="Cover Image"
value={form.coverImageUrl}
@@ -168,7 +168,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
<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">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Input label="Street Address" required value={form.addressLine1} onChange={(e) => set("addressLine1", e.target.value)} />
<Input label="Address Line 2 (optional)" value={form.addressLine2} onChange={(e) => set("addressLine2", e.target.value)} />
<div className="grid grid-cols-2 gap-4">
@@ -187,7 +187,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Social Links</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Input label="Instagram" placeholder="@yourshop" value={form.instagramUrl} onChange={(e) => set("instagramUrl", e.target.value)} />
<Input label="Facebook" placeholder="facebook.com/yourshop" value={form.facebookUrl} onChange={(e) => set("facebookUrl", e.target.value)} />
</div>
@@ -195,7 +195,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Regional Settings</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Select label="Time Zone" value={form.timezone} onChange={(e) => set("timezone", e.target.value)}>
{TIMEZONES.map((tz) => (
<option key={tz} value={tz}>
@@ -224,7 +224,7 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Policies</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Textarea
label="Cancellation Policy"
value={form.cancellationPolicy}
@@ -199,7 +199,7 @@ export function CatalogueManager({ categories }: { categories: CatalogueCategory
</div>
</div>
<div className="rounded-jumbo border-line divide-line divide-y border bg-white">
<div className="rounded-md border-line divide-line divide-y border bg-white">
{category.services.map((service, serviceIndex) => (
<div key={service.id} className="flex items-center gap-3 px-5 py-4">
<div className="flex flex-col">
@@ -11,6 +11,9 @@ 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 { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
import { cn } from "@/lib/utils/cn";
import { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "@/services/loyalty/reward-threshold";
import { updateLoyaltyConfigAction } from "../actions/loyalty-config";
import type { ProgrammeConfigInput } from "@/services/loyalty/loyalty.service";
@@ -20,6 +23,18 @@ const REWARD_TYPES: { value: ProgrammeConfigInput["rewardType"]; label: string }
{ value: "FREE_SERVICE", label: "Free Service" },
];
const FONT_OPTIONS: { value: LoyaltyCardFont; label: string }[] = [
{ value: LoyaltyCardFont.SANS, label: "Sans Serif" },
{ value: LoyaltyCardFont.SERIF, label: "Serif" },
{ value: LoyaltyCardFont.MONO, label: "Monospace" },
];
const TEXT_SIZE_OPTIONS: { value: LoyaltyCardTextSize; label: string }[] = [
{ value: LoyaltyCardTextSize.SM, label: "Small" },
{ value: LoyaltyCardTextSize.MD, label: "Medium" },
{ value: LoyaltyCardTextSize.LG, label: "Large" },
];
export interface LoyaltyConfigFormProps {
businessName: string;
initialConfig: ProgrammeConfigInput;
@@ -70,7 +85,7 @@ export function LoyaltyConfigForm({
return (
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
<section className="rounded-jumbo border-line border bg-white p-6 lg:col-span-2">
<section className="rounded-md border-line border bg-white p-6 lg:col-span-2">
<Switch
label="Loyalty programme enabled"
description="Turn off to stop earning/redeeming everywhere, without losing the configuration below."
@@ -81,14 +96,56 @@ export function LoyaltyConfigForm({
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Reward Rules</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<div className="rounded-md border-line space-y-5 border bg-white p-6">
<Input
label="Stamps required for reward"
label={`Stamps required for reward (1–${MAX_STAMPS_REQUIRED})`}
type="number"
min={1}
max={MAX_STAMPS_REQUIRED}
value={config.stampsRequired}
onChange={(e) => set("stampsRequired", Number(e.target.value))}
onChange={(e) => {
const next = Number(e.target.value);
set("stampsRequired", Number.isFinite(next) ? Math.min(Math.max(next, 1), MAX_STAMPS_REQUIRED) : 1);
}}
/>
<div>
<p className="mb-2 ml-1 text-[10px] font-bold tracking-widest uppercase text-muted">Reward Timing</p>
<div className="space-y-2">
{(
[
{
value: RewardPositionMode.AFTER_ALL_STAMPS,
title: "Reward after all stamps",
description: `Collect all ${config.stampsRequired} stamps, then the reward is given as a separate perk — every position on the card is a normal stamp.`,
},
{
value: RewardPositionMode.FINAL_SLOT_IS_REWARD,
title: "Final position is the reward",
description: `Collect ${resolveRewardThreshold({ stampsRequired: config.stampsRequired, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD })} stamps — the last position on the card is the reward itself, marked visually, not a stamp to fill.`,
},
] as const
).map((option) => {
const selected = config.rewardPosition === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => set("rewardPosition", option.value)}
aria-pressed={selected}
className={cn(
"w-full rounded-xl border p-4 text-left transition-colors",
selected ? "border-accent-loyalty bg-accent-loyalty/10" : "border-line bg-white",
)}
>
<p className="text-sm font-bold">{option.title}</p>
<p className="text-muted mt-1 text-xs">{option.description}</p>
</button>
);
})}
</div>
</div>
<Select label="Reward Type" value={config.rewardType} onChange={(e) => set("rewardType", e.target.value as ProgrammeConfigInput["rewardType"])}>
{REWARD_TYPES.map((t) => (
<option key={t.value} value={t.value}>
@@ -131,7 +188,7 @@ export function LoyaltyConfigForm({
<section className="space-y-3 lg:col-span-2">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Eligibility</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-2 lg:gap-6 lg:space-y-0">
<div className="rounded-md border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-2 lg:gap-6 lg:space-y-0">
<div>
<p className="mb-2 text-xs font-bold">Eligible services (none checked = all services earn stamps)</p>
<div className="space-y-2">
@@ -163,7 +220,7 @@ export function LoyaltyConfigForm({
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules &amp; Expiry</h3>
<div className="rounded-jumbo border-line space-y-5 border bg-white p-6">
<div className="rounded-md border-line space-y-5 border bg-white p-6">
<Switch
label="Apply reward automatically"
description="Redemption is always staff-initiated this phase — this setting is saved but has no automatic effect yet."
@@ -199,19 +256,29 @@ export function LoyaltyConfigForm({
<section className="space-y-3 lg:col-span-2">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Card Appearance</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-[minmax(0,320px)_1fr] lg:gap-8 lg:space-y-0">
<div className="rounded-md border-line space-y-4 border bg-white p-6 lg:grid lg:grid-cols-[minmax(0,320px)_1fr] lg:gap-8 lg:space-y-0">
<div>
<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))}
stampsEarned={Math.max(
resolveRewardThreshold({ stampsRequired: Math.max(config.stampsRequired, 1), rewardPosition: config.rewardPosition }) - 1,
0,
)}
rewardLabel={config.rewardLabel || "Reward"}
termsLine={config.termsLine}
cardBackgroundColor={config.cardBackgroundColor}
cardBackgroundImageUrl={config.cardBackgroundImageUrl}
stampIcon={config.stampIcon}
rewardPosition={config.rewardPosition}
cardTopRightText={config.cardTopRightText}
cardFont={config.cardFont}
cardTopRightTextSize={config.cardTopRightTextSize}
cardTopRightTextColor={config.cardTopRightTextColor}
cardBorderColor={config.cardBorderColor}
cardMemberNumberColor={config.cardMemberNumberColor}
/>
</div>
<div className="mt-4 space-y-4 lg:mt-0">
@@ -226,6 +293,11 @@ export function LoyaltyConfigForm({
onChange={(url) => set("cardBackgroundImageUrl", url ?? undefined)}
category="loyalty"
/>
<ColorPicker
label="Border colour"
value={config.cardBorderColor}
onChange={(hex) => set("cardBorderColor", hex)}
/>
<IconPicker label="Stamp icon" value={config.stampIcon} onChange={(iconName) => set("stampIcon", iconName)} />
<Input
label="Terms line"
@@ -233,6 +305,55 @@ export function LoyaltyConfigForm({
onChange={(e) => set("termsLine", e.target.value)}
placeholder="Valid for 12 months. One reward per visit."
/>
<div className="border-line border-t pt-4">
<p className="mb-3 text-xs font-bold">Top-right card text</p>
<div className="space-y-4">
<Input
label="Text"
value={config.cardTopRightText ?? ""}
onChange={(e) => set("cardTopRightText", e.target.value)}
placeholder="e.g. VIP Member"
hint="Shown above the member number. Leave blank to show only the member number, as today."
/>
<div className="grid grid-cols-2 gap-4">
<Select
label="Font"
value={config.cardFont}
onChange={(e) => set("cardFont", e.target.value as ProgrammeConfigInput["cardFont"])}
>
{FONT_OPTIONS.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</Select>
<Select
label="Text size"
value={config.cardTopRightTextSize}
onChange={(e) => set("cardTopRightTextSize", e.target.value as ProgrammeConfigInput["cardTopRightTextSize"])}
>
{TEXT_SIZE_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</Select>
</div>
<p className="text-muted -mt-2 text-[10px]">Font applies to the whole card, not just this text.</p>
<ColorPicker
label="Text colour"
value={config.cardTopRightTextColor}
onChange={(hex) => set("cardTopRightTextColor", hex)}
/>
</div>
</div>
<ColorPicker
label="Member number colour"
value={config.cardMemberNumberColor}
onChange={(hex) => set("cardMemberNumberColor", hex)}
/>
</div>
</div>
</section>
@@ -147,7 +147,7 @@ export function OpeningHoursForm({ initialRows }: { initialRows: InitialOpeningH
Copy Monday to all days
</button>
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
<div className="divide-line rounded-md border-line divide-y border bg-white">
{days.map((day) => (
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-center gap-3">
@@ -120,7 +120,7 @@ export function ReviewsManager({
return (
<div className="space-y-6">
<section className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<section className="rounded-md border-line space-y-4 border bg-white p-6">
<h3 className="text-sm font-bold">Google Reviews Sync</h3>
{googleRating !== null && (
<p className="text-muted text-sm">
@@ -133,7 +133,7 @@ export function ReviewsManager({
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."
hint="Used to import this business's reviews from Google. Find it by searching your business on Google Maps, opening Share → Embed a map, and copying the id from the embed code — or search “Google Place ID Finder” for Google's own lookup tool. Leave blank to disable review syncing."
/>
<div className="grid grid-cols-2 gap-4">
<Select
@@ -125,7 +125,7 @@ export function StaffDetailPanel({
return (
<div className="space-y-6">
{hasPendingInvitation && (
<section className="rounded-jumbo border-warning/30 bg-warning/10 border p-5">
<section className="rounded-md border-warning/30 bg-warning/10 border p-5">
<p className="text-warning-foreground text-sm font-bold">Invitation pending</p>
<p className="text-muted mt-1 text-xs">
This person hasn&apos;t set a password yet. Email delivery isn&apos;t wired up — send them the link
@@ -148,7 +148,7 @@ export function StaffDetailPanel({
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Role &amp; Permissions</h3>
<div className="rounded-jumbo border-line space-y-4 border bg-white p-6">
<div className="rounded-md border-line space-y-4 border bg-white p-6">
<Select label="Role" value={roleId} onChange={(e) => setRoleId(e.target.value)}>
{roles.map((r) => (
<option key={r.id} value={r.id}>
@@ -174,7 +174,7 @@ export function StaffDetailPanel({
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Services Provided</h3>
<div className="rounded-jumbo border-line space-y-3 border bg-white p-6">
<div className="rounded-md border-line space-y-3 border bg-white p-6">
{allServices.map((s) => (
<Checkbox key={s.id} label={s.name} checked={serviceIds.includes(s.id)} onChange={() => toggleService(s.id)} />
))}
@@ -220,7 +220,7 @@ export function StaffDetailPanel({
<section className="space-y-3">
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Availability</h3>
<div className="rounded-jumbo border-line divide-line divide-y overflow-hidden border bg-white">
<div className="rounded-md border-line divide-line divide-y overflow-hidden border bg-white">
<Link href={`/owner/team/${staffId}/hours`} className="hover:bg-stone flex items-center justify-between px-6 py-4">
<span className="text-sm font-bold">Working Hours</span>
<span className="text-muted text-xs">Edit</span>
@@ -242,7 +242,7 @@ export function StaffDetailPanel({
Reactivate Staff Member
</Button>
) : deactivateConflicts && deactivateConflicts.length > 0 ? (
<div className="border-danger/30 bg-danger/5 space-y-3 rounded-jumbo border p-5">
<div className="border-danger/30 bg-danger/5 space-y-3 rounded-md border p-5">
<p role="alert" className="text-danger text-sm font-medium">
This staff member still has {deactivateConflicts.length} upcoming appointment
{deactivateConflicts.length > 1 ? "s" : ""}. Deactivating won&apos;t cancel them — they&apos;ll need to
@@ -56,7 +56,7 @@ export function ServiceWorkerManager() {
return (
<div
role="status"
className="rounded-jumbo bg-ink fixed inset-x-6 top-4 z-50 mx-auto flex max-w-sm items-center justify-between gap-3 px-5 py-3 text-sm text-white shadow-xl"
className="rounded-md bg-ink fixed inset-x-6 top-4 z-50 mx-auto flex max-w-sm items-center justify-between gap-3 px-5 py-3 text-sm text-white shadow-xl"
>
<span>A new version is available.</span>
<div className="flex items-center gap-3">
@@ -42,7 +42,7 @@ export function WelcomeInstallCard({ businessName }: { businessName: string }) {
if (installed || dismissed || (!canPromptNatively && !ios)) return null;
return (
<div className="rounded-jumbo mb-6 border border-white/10 bg-white/5 p-5 text-white">
<div className="rounded-md mb-6 border border-white/10 bg-white/5 p-5 text-white">
<p className="text-sm font-bold">Add {businessName} to your Home Screen</p>
<p className="mt-1 text-sm text-white/70">Book appointments, manage your visits and receive reminders more easily.</p>
@@ -10,7 +10,7 @@ export function BookFollowUpButton({ appointmentId }: { appointmentId: string })
return (
<Button
variant="secondary"
className="rounded-jumbo border-line border bg-white px-5 py-3 text-sm font-bold"
className="rounded-md border-line border bg-white px-5 py-3 text-sm font-bold"
isLoading={isPending}
onClick={() => startTransition(() => startStaffFollowUpAction(appointmentId))}
>
@@ -69,11 +69,11 @@ export function BreaksList({
</Button>
{breaks.length === 0 ? (
<p className="text-muted rounded-jumbo border border-line bg-white p-5 text-center text-sm">
<p className="text-muted rounded-md border border-line bg-white p-5 text-center text-sm">
No recurring breaks yet.
</p>
) : (
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
<ul className="divide-y divide-line rounded-md border border-line bg-white">
{breaks.map((b) => (
<li key={b.id}>
<button
@@ -57,7 +57,7 @@ export function NotificationPreferencesForm({ initial, action }: NotificationPre
optional. Choose how you&apos;d like to hear about upcoming appointment reminders.
</p>
<div className="rounded-jumbo border-line divide-line divide-y overflow-hidden border bg-white">
<div className="rounded-md border-line divide-line divide-y overflow-hidden border bg-white">
{CHANNEL_ORDER.map((channel) => (
<div key={channel} className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-medium">Reminders via {CHANNEL_LABEL[channel]}</span>
@@ -60,7 +60,7 @@ export function ProfileForm({ initial }: { initial: ProfileFormInitial }) {
onChange={(e) => setBio(e.target.value)}
/>
<div className="rounded-jumbo border border-line bg-white p-5">
<div className="rounded-md border border-line bg-white p-5">
<Switch
label="Visible for online booking"
description="Customers can find and book you when this is on."
@@ -48,11 +48,11 @@ export function TimeOffList({
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Upcoming</p>
{upcoming.length === 0 ? (
<p className="text-muted rounded-jumbo border border-line bg-white p-5 text-center text-sm">
<p className="text-muted rounded-md border border-line bg-white p-5 text-center text-sm">
No upcoming time off.
</p>
) : (
<ul className="divide-y divide-line rounded-jumbo border border-line bg-white">
<ul className="divide-y divide-line rounded-md border border-line bg-white">
{upcoming.map((t) => (
<li key={t.id}>
<button
@@ -74,7 +74,7 @@ export function TimeOffList({
{past.length > 0 && (
<div>
<p className="mb-2 text-xs font-bold uppercase tracking-widest text-muted">Past</p>
<ul className="divide-y divide-line rounded-jumbo border border-line bg-stone/50">
<ul className="divide-y divide-line rounded-md border border-line bg-stone/50">
{past.map((t) => (
<li key={t.id} className="px-5 py-4">
<p className="text-sm font-bold text-muted">{formatRange(t.startDate, t.endDate)}</p>
@@ -160,7 +160,7 @@ export function WorkingHoursForm({
Copy Monday to all days
</button>
<div className="divide-line rounded-jumbo border-line divide-y border bg-white">
<div className="divide-line rounded-md border-line divide-y border bg-white">
{days.map((day) => (
<div key={day.dayOfWeek} className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex items-center gap-3">