Fixes
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:
74 files changed
+1622
-195
No files matched your search
@@ -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'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 & 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 & 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 & 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't set a password yet. Email delivery isn'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 & 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't cancel them — they'll need to
|
||||
|
||||
Reference in new issue
Block a user