Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -3,59 +3,48 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NotificationCategory, NotificationChannel } from "@/generated/prisma/enums";
|
||||
import { saveNotificationPreferencesAction } from "../actions/profile";
|
||||
import { NotificationChannel } from "@/generated/prisma/enums";
|
||||
|
||||
const CATEGORY_LABEL: Record<NotificationCategory, string> = {
|
||||
BOOKING_CONFIRMATION: "New booking confirmed",
|
||||
BOOKING_REMINDER: "Upcoming appointment reminders",
|
||||
BOOKING_CHANGED: "Appointment rescheduled",
|
||||
BOOKING_CANCELLED: "Appointment cancelled",
|
||||
LOYALTY_REWARD_EARNED: "Customer earns a loyalty stamp",
|
||||
LOYALTY_REWARD_AVAILABLE: "Customer reward becomes available",
|
||||
MARKETING: "Product news and tips",
|
||||
};
|
||||
const CATEGORY_ORDER: NotificationCategory[] = [
|
||||
NotificationCategory.BOOKING_CONFIRMATION,
|
||||
NotificationCategory.BOOKING_REMINDER,
|
||||
NotificationCategory.BOOKING_CHANGED,
|
||||
NotificationCategory.BOOKING_CANCELLED,
|
||||
NotificationCategory.LOYALTY_REWARD_EARNED,
|
||||
NotificationCategory.LOYALTY_REWARD_AVAILABLE,
|
||||
NotificationCategory.MARKETING,
|
||||
];
|
||||
const CHANNEL_ORDER: NotificationChannel[] = [
|
||||
NotificationChannel.EMAIL,
|
||||
NotificationChannel.SMS,
|
||||
NotificationChannel.PUSH,
|
||||
];
|
||||
const CHANNEL_LABEL: Record<NotificationChannel, string> = { EMAIL: "Email", SMS: "SMS", PUSH: "Push" };
|
||||
const CHANNEL_ORDER: NotificationChannel[] = [NotificationChannel.EMAIL, NotificationChannel.PUSH];
|
||||
const CHANNEL_LABEL: Record<NotificationChannel, string> = { EMAIL: "Email", PUSH: "Push", IN_APP: "In-app" };
|
||||
|
||||
export interface PreferenceCellState {
|
||||
category: NotificationCategory;
|
||||
channel: NotificationChannel;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export function NotificationPreferencesForm({ initial }: { initial: PreferenceCellState[] }) {
|
||||
/**
|
||||
* Every other notification a barber gets (new appointment, reschedule,
|
||||
* cancellation, schedule change) is required — always sent, no toggle —
|
||||
* so this form only ever shows the one type that's genuinely optional:
|
||||
* appointment reminders. See NotificationService/types.ts,
|
||||
* PREFERENCE_CONTROLLED_TYPES, and docs/phase-7-summary.md — showing a
|
||||
* toggle for a required type would just be a no-op switch that does
|
||||
* nothing when flipped, which the Phase 7 brief explicitly calls out to
|
||||
* avoid.
|
||||
*/
|
||||
export interface NotificationPreferencesFormProps {
|
||||
initial: PreferenceCellState[];
|
||||
/** Recipient-specific — see features/staff/actions/profile.ts (staff) and features/notifications/actions/preferences.ts (customer). */
|
||||
action: (cells: PreferenceCellState[]) => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
export function NotificationPreferencesForm({ initial, action }: NotificationPreferencesFormProps) {
|
||||
const router = useRouter();
|
||||
const [cells, setCells] = useState(initial);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const isEnabled = (category: NotificationCategory, channel: NotificationChannel) =>
|
||||
cells.find((c) => c.category === category && c.channel === channel)?.isEnabled ?? true;
|
||||
const isEnabled = (channel: NotificationChannel) => cells.find((c) => c.channel === channel)?.isEnabled ?? true;
|
||||
|
||||
const toggle = (category: NotificationCategory, channel: NotificationChannel) => {
|
||||
const toggle = (channel: NotificationChannel) => {
|
||||
setSaved(false);
|
||||
setCells((prev) =>
|
||||
prev.map((c) => (c.category === category && c.channel === channel ? { ...c, isEnabled: !c.isEnabled } : c)),
|
||||
);
|
||||
setCells((prev) => prev.map((c) => (c.channel === channel ? { ...c, isEnabled: !c.isEnabled } : c)));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
startTransition(async () => {
|
||||
await saveNotificationPreferencesAction(cells);
|
||||
await action(cells);
|
||||
setSaved(true);
|
||||
router.refresh();
|
||||
});
|
||||
@@ -64,52 +53,30 @@ export function NotificationPreferencesForm({ initial }: { initial: PreferenceCe
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted text-sm">
|
||||
Choose which of these you want to hear about, and how. This controls what you receive —
|
||||
delivery isn't wired up yet.
|
||||
Booking confirmations, changes, and cancellations always notify you — those aren't
|
||||
optional. Choose how you'd like to hear about upcoming appointment reminders.
|
||||
</p>
|
||||
|
||||
<div className="overflow-hidden rounded-jumbo border border-line bg-white">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-line">
|
||||
<th scope="col" className="px-4 py-3 font-bold">
|
||||
Notification
|
||||
</th>
|
||||
{CHANNEL_ORDER.map((channel) => (
|
||||
<th key={channel} scope="col" className="px-2 py-3 text-center text-xs font-bold">
|
||||
{CHANNEL_LABEL[channel]}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{CATEGORY_ORDER.map((category) => (
|
||||
<tr key={category} className="border-b border-line last:border-b-0">
|
||||
<th scope="row" className="px-4 py-3 text-xs font-medium">
|
||||
{CATEGORY_LABEL[category]}
|
||||
</th>
|
||||
{CHANNEL_ORDER.map((channel) => (
|
||||
<td key={channel} className="px-2 py-3 text-center">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isEnabled(category, channel)}
|
||||
aria-label={`${CATEGORY_LABEL[category]} via ${CHANNEL_LABEL[channel]}`}
|
||||
onClick={() => toggle(category, channel)}
|
||||
className={`h-5 w-5 rounded-md border transition-colors ${
|
||||
isEnabled(category, channel) ? "border-accent bg-accent" : "border-line bg-white"
|
||||
}`}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="rounded-jumbo 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>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={isEnabled(channel)}
|
||||
aria-label={`Appointment reminders via ${CHANNEL_LABEL[channel]}`}
|
||||
onClick={() => toggle(channel)}
|
||||
className={`h-5 w-5 rounded-md border transition-colors ${
|
||||
isEnabled(channel) ? "border-accent bg-accent" : "border-line bg-white"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{saved && (
|
||||
<p role="status" className="text-accent text-sm font-medium">
|
||||
<p role="status" className="text-accent-foreground text-sm font-medium">
|
||||
Saved.
|
||||
</p>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user