The shell around every page sets no content width, so each page chose its own. Business Details was centered, Services and Team were pinned left with a dead right half, and Reports, the dashboard, Current Appointment and Settings stretched edge to edge. I added a shared PageContainer with three widths (narrow, default, wide), always centered, and used it on those pages and on the settings pages. Two form layouts had their own bugs: Business Details (your screenshot): six cards in a two-column grid, so a short card next to a tall one left a big gap. It's now two independent columns of similar height. Loyalty: "Reward Rules" and "Rules & Expiry" each sat alone in the left column with an empty right side. They're now side by side. On phones, Business Details now stacks its cards in a slightly different order (Branding moves down). Tablets get a slightly wider column than before. Two things I didn't touch: Customer pages: Home and Book are a phone-width column floating in the middle of a desktop screen, with the bottom tab bar still showing. That's a bigger design decision, so tell me if you want it. Narrow pages: about 30 pages already used a centered narrow column, which looks fine.
This commit is contained in:
10 files changed
+217
-163
No files matched your search
@@ -23,6 +23,7 @@ import {
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { PageContainer } from "@/components/layout/page-container";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { hasPermission, isSuperAdmin } from "@/lib/auth/permissions";
|
import { hasPermission, isSuperAdmin } from "@/lib/auth/permissions";
|
||||||
@@ -135,7 +136,7 @@ export default async function OwnerDashboardPage() {
|
|||||||
leftAction={<InlineNotificationBell href="/owner/notifications" unreadCount={unreadNotificationCount} />}
|
leftAction={<InlineNotificationBell href="/owner/notifications" unreadCount={unreadNotificationCount} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-6 px-6 pt-6">
|
<PageContainer as="div" size="wide" className="space-y-6 pb-0">
|
||||||
<p className="text-muted text-sm">{context.businessName} — today's overview</p>
|
<p className="text-muted text-sm">{context.businessName} — today's overview</p>
|
||||||
|
|
||||||
{summary.warnings.length > 0 && (
|
{summary.warnings.length > 0 && (
|
||||||
@@ -250,7 +251,7 @@ export default async function OwnerDashboardPage() {
|
|||||||
<ManageSection key={section.title} {...section} />
|
<ManageSection key={section.title} {...section} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { PageContainer } from "@/components/layout/page-container";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { hasPermission } from "@/lib/auth/permissions";
|
import { hasPermission } from "@/lib/auth/permissions";
|
||||||
@@ -73,7 +74,7 @@ export default async function OwnerReportsPage({
|
|||||||
return (
|
return (
|
||||||
<div className="pb-12">
|
<div className="pb-12">
|
||||||
<TopNav title="Reports" onBack sticky className="bg-stone" />
|
<TopNav title="Reports" onBack sticky className="bg-stone" />
|
||||||
<main className="space-y-6 px-6 py-6">
|
<PageContainer size="wide" className="space-y-6">
|
||||||
<p className="text-muted text-sm">
|
<p className="text-muted text-sm">
|
||||||
{fromDate} to {toDate}
|
{fromDate} to {toDate}
|
||||||
</p>
|
</p>
|
||||||
@@ -196,7 +197,7 @@ export default async function OwnerReportsPage({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</PageContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
|
import { PageContainer } from "@/components/layout/page-container";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { hasPermission } from "@/lib/auth/permissions";
|
import { hasPermission } from "@/lib/auth/permissions";
|
||||||
@@ -27,7 +28,7 @@ export default async function OwnerServicesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="pb-12">
|
<div className="pb-12">
|
||||||
<TopNav title="Services" onBack sticky className="bg-stone" />
|
<TopNav title="Services" onBack sticky className="bg-stone" />
|
||||||
<main className="px-6 py-6 lg:max-w-3xl">
|
<PageContainer>
|
||||||
<CatalogueManager
|
<CatalogueManager
|
||||||
categories={categories.map((c) => ({
|
categories={categories.map((c) => ({
|
||||||
id: c.id,
|
id: c.id,
|
||||||
@@ -48,7 +49,7 @@ export default async function OwnerServicesPage() {
|
|||||||
})),
|
})),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</main>
|
</PageContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
|||||||
import { Plus, Search, ShieldCheck, ShieldPlus } from "lucide-react";
|
import { Plus, Search, ShieldCheck, ShieldPlus } from "lucide-react";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
|
import { PageContainer } from "@/components/layout/page-container";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { hasPermission } from "@/lib/auth/permissions";
|
import { hasPermission } from "@/lib/auth/permissions";
|
||||||
@@ -54,7 +55,7 @@ export default async function OwnerTeamPage({
|
|||||||
return (
|
return (
|
||||||
<div className="pb-12">
|
<div className="pb-12">
|
||||||
<TopNav title="Team" onBack sticky className="bg-stone" action={headerAction} />
|
<TopNav title="Team" onBack sticky className="bg-stone" action={headerAction} />
|
||||||
<main className="px-6 py-6 lg:max-w-4xl">
|
<PageContainer>
|
||||||
<form action="/owner/team" method="GET" className="relative mb-4">
|
<form action="/owner/team" method="GET" className="relative mb-4">
|
||||||
<Search className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
|
<Search className="text-muted absolute top-1/2 left-4 h-4 w-4 -translate-y-1/2" aria-hidden="true" />
|
||||||
<label htmlFor="team-search" className="sr-only">
|
<label htmlFor="team-search" className="sr-only">
|
||||||
@@ -106,7 +107,7 @@ export default async function OwnerTeamPage({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</PageContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import { redirect } from "next/navigation";
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { StatusPill } from "@/components/ui/status-pill";
|
import { StatusPill } from "@/components/ui/status-pill";
|
||||||
import { EmptyState } from "@/components/feedback/empty-state";
|
import { EmptyState } from "@/components/feedback/empty-state";
|
||||||
|
import { PageContainer } from "@/components/layout/page-container";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { prisma } from "@/lib/db/prisma";
|
import { prisma } from "@/lib/db/prisma";
|
||||||
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
import { getActingStaff } from "@/lib/staff/get-acting-staff";
|
||||||
@@ -60,13 +61,13 @@ export default async function StaffCurrentPage() {
|
|||||||
className="bg-stone"
|
className="bg-stone"
|
||||||
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
||||||
/>
|
/>
|
||||||
<div className="px-6 pt-6">
|
<PageContainer as="div" size="narrow" className="pb-0">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Scissors}
|
icon={Scissors}
|
||||||
title="Nothing scheduled right now"
|
title="Nothing scheduled right now"
|
||||||
description="Your next appointment today will show up here."
|
description="Your next appointment today will show up here."
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -98,7 +99,7 @@ export default async function StaffCurrentPage() {
|
|||||||
className="bg-stone"
|
className="bg-stone"
|
||||||
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 px-6 pt-6">
|
<PageContainer as="div" size="narrow" className="space-y-4 pb-0">
|
||||||
<Card className="space-y-5 p-6">
|
<Card className="space-y-5 p-6">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -243,7 +244,7 @@ export default async function StaffCurrentPage() {
|
|||||||
)}
|
)}
|
||||||
<BookFollowUpButton appointmentId={current.id} className="w-full" />
|
<BookFollowUpButton appointmentId={current.id} className="w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
|||||||
import { Bell, CalendarClock, CalendarOff, CalendarSync, Clock, type LucideIcon } from "lucide-react";
|
import { Bell, CalendarClock, CalendarOff, CalendarSync, Clock, type LucideIcon } from "lucide-react";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { ProfileCard } from "@/components/ui/profile-card";
|
import { ProfileCard } from "@/components/ui/profile-card";
|
||||||
|
import { PageContainer } from "@/components/layout/page-container";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
import { SignOutButton } from "@/features/auth/components/sign-out-button";
|
||||||
import { getNavRole } from "@/lib/auth/permissions";
|
import { getNavRole } from "@/lib/auth/permissions";
|
||||||
@@ -81,7 +82,7 @@ export default async function StaffSettingsPage() {
|
|||||||
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
leftAction={<InlineNotificationBell href="/staff/notifications" unreadCount={unreadNotificationCount} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-6 px-6 pt-6">
|
<PageContainer as="div" size="narrow" className="space-y-6 pb-0">
|
||||||
<ProfileCard
|
<ProfileCard
|
||||||
name={session.user.name ?? "Account"}
|
name={session.user.name ?? "Account"}
|
||||||
meta={session.user.email}
|
meta={session.user.email}
|
||||||
@@ -110,7 +111,7 @@ export default async function StaffSettingsPage() {
|
|||||||
<SignOutButton />
|
<SignOutButton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ElementType, ReactNode } from "react";
|
||||||
|
import { cn } from "@/lib/utils/cn";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one place staff/owner page content gets its desktop width. The shell
|
||||||
|
* (StaffShell) deliberately sets no content width of its own, so before
|
||||||
|
* this each page picked its own — some centered, some pinned left, some
|
||||||
|
* stretched edge to edge — and the same sidebar layout looked different
|
||||||
|
* from one page to the next.
|
||||||
|
*
|
||||||
|
* Always centred in the space beside the sidebar, never pinned left:
|
||||||
|
* - `narrow` (max-w-2xl) — a single form or detail column.
|
||||||
|
* - `default` (max-w-4xl) — settings pages and lists; room for a
|
||||||
|
* two-column form on desktop.
|
||||||
|
* - `wide` (max-w-6xl) — dashboards and reports with card grids.
|
||||||
|
* Phones are unaffected: the width cap only bites once the viewport is
|
||||||
|
* wider than the cap, and the 24px gutter is the same as before.
|
||||||
|
*/
|
||||||
|
const SIZE_CLASS = {
|
||||||
|
narrow: "max-w-2xl",
|
||||||
|
default: "max-w-4xl",
|
||||||
|
wide: "max-w-6xl",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PageContainerSize = keyof typeof SIZE_CLASS;
|
||||||
|
|
||||||
|
export function PageContainer({
|
||||||
|
size = "default",
|
||||||
|
as: Component = "main",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
size?: PageContainerSize;
|
||||||
|
as?: ElementType;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Component className={cn("mx-auto w-full px-6 py-6", SIZE_CLASS[size], className)}>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { PageContainer } from "@/components/layout/page-container";
|
||||||
import { TopNav } from "@/components/navigation/top-nav";
|
import { TopNav } from "@/components/navigation/top-nav";
|
||||||
import { cn } from "@/lib/utils/cn";
|
|
||||||
|
|
||||||
export interface SettingsPageShellProps {
|
export interface SettingsPageShellProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -20,7 +20,9 @@ export function SettingsPageShell({ title, children, mainClassName }: SettingsPa
|
|||||||
return (
|
return (
|
||||||
<div className="pb-12">
|
<div className="pb-12">
|
||||||
<TopNav title={title} onBack sticky className="bg-stone" />
|
<TopNav title={title} onBack sticky className="bg-stone" />
|
||||||
<main className={cn("mx-auto max-w-2xl px-6 py-6 lg:max-w-4xl", mainClassName)}>{children}</main>
|
<PageContainer size="default" className={mainClassName}>
|
||||||
|
{children}
|
||||||
|
</PageContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -106,127 +106,130 @@ export function BusinessDetailsForm({ initial }: { initial: BusinessDetailsIniti
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 lg:grid lg:grid-cols-2 lg:items-start lg:gap-6 lg:space-y-0">
|
<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">
|
{/* Two independent columns, not a row-by-row grid: paired cards of
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Profile</h3>
|
different heights left a large empty gap under the shorter one.
|
||||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
Column contents are chosen so both end at about the same height.
|
||||||
<Input label="Business Name" required value={form.name} onChange={(e) => set("name", e.target.value)} />
|
On mobile the wrappers are single-column and simply stack. */}
|
||||||
<MediaPicker label="Logo" value={form.logoUrl} onChange={(url) => set("logoUrl", url)} category="branding" />
|
<div className="space-y-6">
|
||||||
<Textarea label="Description" value={form.description} onChange={(e) => set("description", e.target.value)} />
|
<section className="space-y-3">
|
||||||
</div>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Profile</h3>
|
||||||
</section>
|
<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)} />
|
||||||
<section className="space-y-3">
|
<MediaPicker label="Logo" value={form.logoUrl} onChange={(url) => set("logoUrl", url)} category="branding" />
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Branding</h3>
|
<Textarea label="Description" value={form.description} onChange={(e) => set("description", e.target.value)} />
|
||||||
<div className="rounded-md 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."
|
|
||||||
/>
|
|
||||||
</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-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">
|
|
||||||
<Input label="City" required value={form.city} onChange={(e) => set("city", e.target.value)} />
|
|
||||||
<Input label="Postcode" required value={form.postalCode} onChange={(e) => set("postalCode", e.target.value)} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
</section>
|
||||||
<Input label="Region (optional)" value={form.region} onChange={(e) => set("region", e.target.value)} />
|
<section className="space-y-3">
|
||||||
<Input label="Country" value={form.country} onChange={(e) => set("country", e.target.value)} />
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Contact & Address</h3>
|
||||||
|
<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">
|
||||||
|
<Input label="City" required value={form.city} onChange={(e) => set("city", e.target.value)} />
|
||||||
|
<Input label="Postcode" required value={form.postalCode} onChange={(e) => set("postalCode", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input label="Region (optional)" value={form.region} onChange={(e) => set("region", e.target.value)} />
|
||||||
|
<Input label="Country" value={form.country} onChange={(e) => set("country", e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<Input label="Phone" type="tel" value={form.phone} onChange={(e) => set("phone", e.target.value)} />
|
||||||
|
<Input label="Email" type="email" value={form.email} onChange={(e) => set("email", e.target.value)} />
|
||||||
|
<Input label="Website" type="url" placeholder="https://" value={form.website} onChange={(e) => set("website", e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<Input label="Phone" type="tel" value={form.phone} onChange={(e) => set("phone", e.target.value)} />
|
</section>
|
||||||
<Input label="Email" type="email" value={form.email} onChange={(e) => set("email", e.target.value)} />
|
<section className="space-y-3">
|
||||||
<Input label="Website" type="url" placeholder="https://" value={form.website} onChange={(e) => set("website", e.target.value)} />
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Social Links</h3>
|
||||||
</div>
|
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
</section>
|
<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)} />
|
||||||
<section className="space-y-3">
|
</div>
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Social Links</h3>
|
</section>
|
||||||
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
<section className="space-y-3">
|
||||||
<Input label="Instagram" placeholder="@yourshop" value={form.instagramUrl} onChange={(e) => set("instagramUrl", e.target.value)} />
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Regional Settings</h3>
|
||||||
<Input label="Facebook" placeholder="facebook.com/yourshop" value={form.facebookUrl} onChange={(e) => set("facebookUrl", e.target.value)} />
|
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
</div>
|
<Select label="Time Zone" value={form.timezone} onChange={(e) => set("timezone", e.target.value)}>
|
||||||
</section>
|
{TIMEZONES.map((tz) => (
|
||||||
|
<option key={tz} value={tz}>
|
||||||
<section className="space-y-3">
|
{tz}
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Regional Settings</h3>
|
|
||||||
<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}>
|
|
||||||
{tz}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Select label="Currency" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
|
|
||||||
{CURRENCIES.map((c) => (
|
|
||||||
<option key={c} value={c}>
|
|
||||||
{c}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Select label="Locale" value={form.locale} onChange={(e) => set("locale", e.target.value)}>
|
|
||||||
{LOCALES.map((l) => (
|
|
||||||
<option key={l} value={l}>
|
|
||||||
{l}
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Select label="Currency" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
|
||||||
|
{CURRENCIES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select label="Locale" value={form.locale} onChange={(e) => set("locale", e.target.value)}>
|
||||||
|
{LOCALES.map((l) => (
|
||||||
|
<option key={l} value={l}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</section>
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Policies</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Branding</h3>
|
||||||
<div className="rounded-md 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
|
<MediaPicker
|
||||||
label="Cancellation Policy"
|
label="Cover Image"
|
||||||
value={form.cancellationPolicy}
|
value={form.coverImageUrl}
|
||||||
onChange={(e) => set("cancellationPolicy", e.target.value)}
|
onChange={(url) => set("coverImageUrl", url)}
|
||||||
/>
|
category="branding"
|
||||||
<Textarea
|
hint="Shown at the top of the customer Home page."
|
||||||
label="Late Arrival Policy"
|
/>
|
||||||
value={form.lateArrivalPolicy}
|
<MediaPicker
|
||||||
onChange={(e) => set("lateArrivalPolicy", e.target.value)}
|
label="Welcome Background — Desktop"
|
||||||
/>
|
value={form.welcomeBackgroundDesktopUrl}
|
||||||
<Textarea label="Booking Terms" value={form.bookingTerms} onChange={(e) => set("bookingTerms", e.target.value)} />
|
onChange={(url) => set("welcomeBackgroundDesktopUrl", url)}
|
||||||
<Input
|
category="welcome"
|
||||||
label="Privacy Policy URL"
|
/>
|
||||||
type="url"
|
<MediaPicker
|
||||||
placeholder="https://"
|
label="Welcome Background — Mobile"
|
||||||
value={form.privacyPolicyUrl}
|
value={form.welcomeBackgroundMobileUrl}
|
||||||
onChange={(e) => set("privacyPolicyUrl", e.target.value)}
|
onChange={(url) => set("welcomeBackgroundMobileUrl", url)}
|
||||||
/>
|
category="welcome"
|
||||||
</div>
|
hint="Shown on the sign-in/welcome screen, before someone signs in."
|
||||||
</section>
|
/>
|
||||||
|
<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."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Policies</h3>
|
||||||
|
<div className="rounded-md border-line space-y-4 border bg-white p-6">
|
||||||
|
<Textarea
|
||||||
|
label="Cancellation Policy"
|
||||||
|
value={form.cancellationPolicy}
|
||||||
|
onChange={(e) => set("cancellationPolicy", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="Late Arrival Policy"
|
||||||
|
value={form.lateArrivalPolicy}
|
||||||
|
onChange={(e) => set("lateArrivalPolicy", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Textarea label="Booking Terms" value={form.bookingTerms} onChange={(e) => set("bookingTerms", e.target.value)} />
|
||||||
|
<Input
|
||||||
|
label="Privacy Policy URL"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://"
|
||||||
|
value={form.privacyPolicyUrl}
|
||||||
|
onChange={(e) => set("privacyPolicyUrl", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 lg:col-span-2">
|
<div className="space-y-4 lg:col-span-2">
|
||||||
{error && (
|
{error && (
|
||||||
|
|||||||
@@ -187,38 +187,6 @@ export function LoyaltyConfigForm({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<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-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">
|
|
||||||
{services.map((s) => (
|
|
||||||
<Checkbox
|
|
||||||
key={s.id}
|
|
||||||
label={s.name}
|
|
||||||
checked={eligibleServiceIds.includes(s.id)}
|
|
||||||
onChange={() => toggleInList(eligibleServiceIds, setEligibleServiceIds, s.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-line border-t pt-4 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
|
|
||||||
<p className="mb-2 text-xs font-bold">Eligible staff (none checked = all staff earn stamps)</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{staff.map((s) => (
|
|
||||||
<Checkbox
|
|
||||||
key={s.id}
|
|
||||||
label={s.name}
|
|
||||||
checked={eligibleStaffIds.includes(s.id)}
|
|
||||||
onChange={() => toggleInList(eligibleStaffIds, setEligibleStaffIds, s.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules & Expiry</h3>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Rules & Expiry</h3>
|
||||||
<div className="rounded-md border-line space-y-5 border bg-white p-6">
|
<div className="rounded-md border-line space-y-5 border bg-white p-6">
|
||||||
@@ -255,6 +223,38 @@ export function LoyaltyConfigForm({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<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-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">
|
||||||
|
{services.map((s) => (
|
||||||
|
<Checkbox
|
||||||
|
key={s.id}
|
||||||
|
label={s.name}
|
||||||
|
checked={eligibleServiceIds.includes(s.id)}
|
||||||
|
onChange={() => toggleInList(eligibleServiceIds, setEligibleServiceIds, s.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-line border-t pt-4 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
|
||||||
|
<p className="mb-2 text-xs font-bold">Eligible staff (none checked = all staff earn stamps)</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{staff.map((s) => (
|
||||||
|
<Checkbox
|
||||||
|
key={s.id}
|
||||||
|
label={s.name}
|
||||||
|
checked={eligibleStaffIds.includes(s.id)}
|
||||||
|
onChange={() => toggleInList(eligibleStaffIds, setEligibleStaffIds, s.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="space-y-3 lg:col-span-2">
|
<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>
|
<h3 className="text-muted ml-1 text-[10px] font-bold tracking-widest uppercase">Card Appearance</h3>
|
||||||
<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 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">
|
||||||
|
|||||||
Reference in new issue
Block a user