COMPLETE RETHINK — from monitoring dashboard to message design studio.
## The Big Idea
Aaisha doesn't need a dashboard that says 'is it working?'
She needs a studio where she can SEE what Ahmed sees on his phone,
EDIT the words, TEST different approaches, and DESIGN cross-channel
sequences. The WhatsApp phone mockup is the star.
## New: Phone Mockups (3 channels)
- WhatsApp: green bubbles, blue ticks, org avatar, chat wallpaper,
full formatting (*bold*, _italic_, `code`, ━━━ dividers)
- Email: macOS mail client chrome, From header, subject line
- SMS: iOS Messages style, grey bubbles, contact avatar
## New: Template Editor
- Editable templates per step (receipt, day 2, 7, 14) per channel
- Live preview in phone mockup as you type
- Variable insertion chips: {{name}}, {{amount}}, {{reference}}, etc.
- Subject line editor for email channel
- Character count + SMS segment counter
## New: A/B Testing
- Create Variant B of any step/channel message
- 50/50 split traffic automatically
- Track sent count + conversion rate (paid after receiving)
- Side-by-side stats: 'A: 33% paid, B: 54% paid ★'
- Delete variant to revert to single message
## New: Channel Strategy Matrix
- 3 presets: Waterfall (default), Belt & Suspenders, Escalation
- Visual matrix: steps × channels with status indicators
- 1st = primary, fb = fallback, + = parallel send
- Waterfall: WhatsApp → SMS → Email (most cost-effective)
- Belt & Suspenders: all channels for receipts + final
- Escalation: start gentle (WA only), add channels as urgency increases
## New: Customizable Timing
- Each step's delay is editable inline (dropdown next to phone)
- Default: Day 2, Day 7, Day 14
- Can change to any schedule: Day 1, Day 3, Day 21, Day 28
## Schema: 2 new models
- MessageTemplate: per-org editable templates with A/B variants
(step, channel, variant, body, subject, splitPercent, sentCount, convertedCount)
- AutomationConfig: per-org timing + strategy + channel matrix
## API: /api/automations (GET/PATCH/DELETE)
- GET seeds defaults on first load (12 templates: 4 steps × 3 channels)
- PATCH upserts templates and config
- DELETE removes variant B and resets A to 100%
## Default templates (src/lib/templates.ts)
Extracted from hardcoded whatsapp.ts + reminders.ts into editable templates:
- WhatsApp: receipt, gentle, impact, final (with emoji + formatting)
- Email: receipt, gentle, impact, final (with cancel/pledge URLs)
- SMS: receipt, gentle, impact, final (160-char optimized)
## Architecture
templates.ts → resolvePreview() fills {{variables}} with examples
templates.ts → resolveTemplate() fills {{variables}} with real data
messaging.ts → sendToDonor() routes via channel waterfall
automations/route.ts → seeds + CRUD for templates + config
## Visual: Step timeline at top
4 tabs across the top with emoji, timing, description
Active step is dark (111827), others are white
Click to switch — editor and phone update together
## Layout
[Step Timeline — 4 tabs across top]
[Phone Mockup (left) | Editor (right)]
[Channel Strategy — expandable matrix]
[Live Feed — condensed stats + scheduled + messages]
872 lines
42 KiB
TypeScript
872 lines
42 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useCallback, useRef } from "react"
|
|
import {
|
|
Loader2, MessageCircle, Mail, Smartphone, Check, X, Clock,
|
|
ChevronDown, Send, Zap, AlertTriangle, Plus, Trash2,
|
|
Pencil, RotateCcw, CheckCheck
|
|
} from "lucide-react"
|
|
import Link from "next/link"
|
|
import { resolvePreview, TEMPLATE_VARIABLES, STEP_META, STRATEGY_PRESETS } from "@/lib/templates"
|
|
|
|
/**
|
|
* /dashboard/automations — THE MESSAGE DESIGN STUDIO
|
|
*
|
|
* This is not a monitoring dashboard. This is where Aaisha designs
|
|
* the messages her donors receive. She sees exactly what Ahmed
|
|
* sees on his phone — a WhatsApp-native mockup with green bubbles,
|
|
* blue ticks, and her charity's name in the header.
|
|
*
|
|
* Layout:
|
|
* ┌─────────────────────────────────────────────────────────────┐
|
|
* │ STEP TIMELINE (4 tabs across the top) │
|
|
* ├─────────────────────┬───────────────────────────────────────┤
|
|
* │ PHONE MOCKUP │ EDITOR │
|
|
* │ (WhatsApp-native) │ Channel tabs + textarea + variables │
|
|
* │ │ A/B variant toggle │
|
|
* │ │ Save / Test / Reset │
|
|
* ├─────────────────────┴───────────────────────────────────────┤
|
|
* │ CHANNEL STRATEGY (matrix + presets) │
|
|
* ├─────────────────────────────────────────────────────────────┤
|
|
* │ STATS + FEED (condensed at bottom) │
|
|
* └─────────────────────────────────────────────────────────────┘
|
|
*/
|
|
|
|
// ─── Types ───────────────────────────────────────────────────
|
|
|
|
interface Template {
|
|
id: string; step: number; channel: string; variant: string
|
|
name: string; subject: string | null; body: string
|
|
isActive: boolean; splitPercent: number
|
|
sentCount: number; convertedCount: number
|
|
}
|
|
|
|
interface Config {
|
|
isActive: boolean; step1Delay: number; step2Delay: number; step3Delay: number
|
|
strategy: string; channelMatrix: Record<string, string[]> | null
|
|
}
|
|
|
|
interface ChannelStatus { whatsapp: boolean; email: { provider: string; fromAddress: string } | null; sms: { provider: string; fromNumber: string } | null }
|
|
interface ChannelStats { whatsapp: { sent: number; failed: number }; email: { sent: number; failed: number }; sms: { sent: number; failed: number }; total: number; deliveryRate: number }
|
|
interface MessageEntry { id: string; channel: string; messageType: string; donorName: string | null; success: boolean; error: string | null; createdAt: string }
|
|
interface PendingEntry { id: string; donorName: string | null; amountPence: number; step: number; channel: string; scheduledAt: string }
|
|
|
|
const CHANNELS = ["whatsapp", "email", "sms"] as const
|
|
type Channel = typeof CHANNELS[number]
|
|
const CH_META: Record<Channel, { icon: typeof MessageCircle; color: string; label: string }> = {
|
|
whatsapp: { icon: MessageCircle, color: "#25D366", label: "WhatsApp" },
|
|
email: { icon: Mail, color: "#1E40AF", label: "Email" },
|
|
sms: { icon: Smartphone, color: "#F59E0B", label: "SMS" },
|
|
}
|
|
|
|
// ─── Page ────────────────────────────────────────────────────
|
|
|
|
export default function AutomationsPage() {
|
|
const [loading, setLoading] = useState(true)
|
|
const [templates, setTemplates] = useState<Template[]>([])
|
|
const [config, setConfig] = useState<Config | null>(null)
|
|
const [channels, setChannels] = useState<ChannelStatus | null>(null)
|
|
const [stats, setStats] = useState<ChannelStats | null>(null)
|
|
const [history, setHistory] = useState<MessageEntry[]>([])
|
|
const [pending, setPending] = useState<PendingEntry[]>([])
|
|
|
|
// Current editor state
|
|
const [activeStep, setActiveStep] = useState(0)
|
|
const [activeChannel, setActiveChannel] = useState<Channel>("whatsapp")
|
|
const [activeVariant, setActiveVariant] = useState("A")
|
|
const [editBody, setEditBody] = useState("")
|
|
const [editSubject, setEditSubject] = useState("")
|
|
const [editName, setEditName] = useState("")
|
|
const [dirty, setDirty] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [saved, setSaved] = useState(false)
|
|
const [showStrategy, setShowStrategy] = useState(false)
|
|
const [showFeed, setShowFeed] = useState(false)
|
|
|
|
const editorRef = useRef<HTMLTextAreaElement>(null)
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const res = await fetch("/api/automations")
|
|
const data = await res.json()
|
|
if (data.templates) setTemplates(data.templates)
|
|
if (data.config) setConfig(data.config)
|
|
if (data.channels) setChannels(data.channels)
|
|
if (data.stats) setStats(data.stats)
|
|
if (data.history) setHistory(data.history)
|
|
if (data.pending) setPending(data.pending)
|
|
} catch { /* */ }
|
|
setLoading(false)
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
// Sync editor when selection changes
|
|
useEffect(() => {
|
|
const tpl = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === activeVariant)
|
|
if (tpl) {
|
|
setEditBody(tpl.body)
|
|
setEditSubject(tpl.subject || "")
|
|
setEditName(tpl.name)
|
|
setDirty(false)
|
|
} else {
|
|
setEditBody("")
|
|
setEditSubject("")
|
|
setEditName(STEP_META[activeStep]?.label || "")
|
|
setDirty(false)
|
|
}
|
|
}, [activeStep, activeChannel, activeVariant, templates])
|
|
|
|
const currentTemplate = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === activeVariant)
|
|
const variantB = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === "B")
|
|
const hasVariantB = !!variantB
|
|
|
|
const activeChannels = [
|
|
channels?.whatsapp ? "whatsapp" : null,
|
|
channels?.email ? "email" : null,
|
|
channels?.sms ? "sms" : null,
|
|
].filter(Boolean) as Channel[]
|
|
|
|
// ─── Actions ──────────────────────────────
|
|
|
|
const saveTemplate = async () => {
|
|
setSaving(true)
|
|
try {
|
|
await fetch("/api/automations", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
templates: [{
|
|
step: activeStep,
|
|
channel: activeChannel,
|
|
variant: activeVariant,
|
|
name: editName,
|
|
subject: activeChannel === "email" ? editSubject : null,
|
|
body: editBody,
|
|
splitPercent: hasVariantB ? 50 : 100,
|
|
}],
|
|
}),
|
|
})
|
|
setSaved(true)
|
|
setDirty(false)
|
|
setTimeout(() => setSaved(false), 2000)
|
|
await load()
|
|
} catch { /* */ }
|
|
setSaving(false)
|
|
}
|
|
|
|
const createVariantB = async () => {
|
|
const tplA = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === "A")
|
|
if (!tplA) return
|
|
setSaving(true)
|
|
try {
|
|
await fetch("/api/automations", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
templates: [
|
|
{ step: activeStep, channel: activeChannel, variant: "A", name: tplA.name, subject: tplA.subject, body: tplA.body, splitPercent: 50 },
|
|
{ step: activeStep, channel: activeChannel, variant: "B", name: tplA.name + " (B)", subject: tplA.subject, body: tplA.body, splitPercent: 50 },
|
|
],
|
|
}),
|
|
})
|
|
setActiveVariant("B")
|
|
await load()
|
|
} catch { /* */ }
|
|
setSaving(false)
|
|
}
|
|
|
|
const deleteVariantB = async () => {
|
|
setSaving(true)
|
|
try {
|
|
await fetch("/api/automations", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ step: activeStep, channel: activeChannel, variant: "B" }),
|
|
})
|
|
setActiveVariant("A")
|
|
await load()
|
|
} catch { /* */ }
|
|
setSaving(false)
|
|
}
|
|
|
|
const saveStrategy = async (strategy: string, matrix: Record<string, string[]>) => {
|
|
try {
|
|
await fetch("/api/automations", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ config: { strategy, channelMatrix: matrix } }),
|
|
})
|
|
await load()
|
|
} catch { /* */ }
|
|
}
|
|
|
|
const saveTiming = async (key: string, value: number) => {
|
|
try {
|
|
await fetch("/api/automations", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ config: { [key]: value } }),
|
|
})
|
|
await load()
|
|
} catch { /* */ }
|
|
}
|
|
|
|
const insertVariable = (key: string) => {
|
|
const el = editorRef.current
|
|
if (!el) return
|
|
const start = el.selectionStart
|
|
const end = el.selectionEnd
|
|
const insert = `{{${key}}}`
|
|
const newBody = editBody.slice(0, start) + insert + editBody.slice(end)
|
|
setEditBody(newBody)
|
|
setDirty(true)
|
|
setTimeout(() => {
|
|
el.focus()
|
|
el.setSelectionRange(start + insert.length, start + insert.length)
|
|
}, 0)
|
|
}
|
|
|
|
// ─── Render ───────────────────────────────
|
|
|
|
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 text-[#1E40AF] animate-spin" /></div>
|
|
|
|
const noChannels = activeChannels.length === 0
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
|
|
{/* ── Header ── */}
|
|
<div className="flex items-end justify-between">
|
|
<div>
|
|
<p className="text-[10px] font-bold text-gray-400 uppercase tracking-widest">Message design studio</p>
|
|
<h1 className="text-3xl font-black text-[#111827] tracking-tight">Automations</h1>
|
|
</div>
|
|
{stats && stats.total > 0 && (
|
|
<div className="text-right">
|
|
<p className="text-2xl font-black text-[#111827]">{stats.total}</p>
|
|
<p className="text-[10px] text-gray-400">messages this week · {stats.deliveryRate}% delivered</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── No channels warning ── */}
|
|
{noChannels && (
|
|
<div className="bg-[#FEF3C7] border-l-2 border-[#F59E0B] p-4 flex items-start gap-3">
|
|
<AlertTriangle className="h-4 w-4 text-[#F59E0B] mt-0.5 shrink-0" />
|
|
<div>
|
|
<p className="text-sm font-bold text-[#111827]">No channels connected yet</p>
|
|
<p className="text-xs text-gray-600 mt-0.5">Connect WhatsApp in <Link href="/dashboard/settings" className="text-[#1E40AF] font-bold hover:underline">Settings</Link> to start sending messages. You can design your messages now and they'll start sending once connected.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Step Timeline ── */}
|
|
<div className="flex gap-px bg-gray-200">
|
|
{STEP_META.map((s, i) => {
|
|
const isActive = activeStep === i
|
|
const delay = i === 0 ? "Instant" : i === 1 ? `Day ${config?.step1Delay || 2}` : i === 2 ? `Day ${config?.step2Delay || 7}` : `Day ${config?.step3Delay || 14}`
|
|
const tplCount = templates.filter(t => t.step === i).length
|
|
return (
|
|
<button
|
|
key={i}
|
|
onClick={() => { setActiveStep(i); setActiveVariant("A") }}
|
|
className={`flex-1 py-3 px-4 text-left transition-colors ${isActive ? "bg-[#111827]" : "bg-white hover:bg-[#F9FAFB]"}`}
|
|
>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className={`text-lg ${isActive ? "" : ""}`}>{s.icon}</span>
|
|
<span className={`text-[9px] font-bold px-1.5 py-0.5 ${isActive ? "bg-white/10 text-gray-400" : "bg-gray-100 text-gray-500"}`}>
|
|
{delay}
|
|
</span>
|
|
</div>
|
|
<p className={`text-sm font-bold ${isActive ? "text-white" : "text-[#111827]"}`}>{s.label}</p>
|
|
<p className={`text-[10px] mt-0.5 ${isActive ? "text-gray-400" : "text-gray-400"}`}>
|
|
{s.desc}
|
|
</p>
|
|
{tplCount > 3 && (
|
|
<span className={`text-[8px] font-bold mt-1 inline-block px-1 py-0.5 ${isActive ? "bg-[#1E40AF] text-white" : "bg-[#1E40AF]/10 text-[#1E40AF]"}`}>
|
|
A/B test
|
|
</span>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* ── Main: Phone + Editor ── */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-[340px_1fr] gap-6">
|
|
|
|
{/* ── LEFT: Phone Mockup ── */}
|
|
<div className="flex flex-col items-center">
|
|
<PhoneMockup
|
|
channel={activeChannel}
|
|
body={editBody}
|
|
subject={editSubject}
|
|
orgName={channels?.email?.fromAddress?.split("@")[1]?.split(".")[0] || "Your Charity"}
|
|
/>
|
|
|
|
{/* Step timing (editable) */}
|
|
{activeStep > 0 && (
|
|
<div className="mt-3 flex items-center gap-2 text-[10px] text-gray-500">
|
|
<Clock className="h-3 w-3" />
|
|
<span>Send</span>
|
|
<select
|
|
value={activeStep === 1 ? config?.step1Delay : activeStep === 2 ? config?.step2Delay : config?.step3Delay}
|
|
onChange={e => {
|
|
const key = activeStep === 1 ? "step1Delay" : activeStep === 2 ? "step2Delay" : "step3Delay"
|
|
saveTiming(key, parseInt(e.target.value))
|
|
}}
|
|
className="border border-gray-200 px-1.5 py-0.5 text-[10px] font-bold text-[#111827] bg-white"
|
|
>
|
|
{[1, 2, 3, 5, 7, 10, 14, 21, 28].map(d => <option key={d} value={d}>{d} day{d > 1 ? "s" : ""}</option>)}
|
|
</select>
|
|
<span>after pledge (if not paid)</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── RIGHT: Editor ── */}
|
|
<div className="space-y-4">
|
|
|
|
{/* Channel tabs */}
|
|
<div className="flex gap-px bg-gray-200">
|
|
{CHANNELS.map(ch => {
|
|
const m = CH_META[ch]
|
|
const isActive = activeChannel === ch
|
|
const isLive = activeChannels.includes(ch)
|
|
const Icon = m.icon
|
|
return (
|
|
<button
|
|
key={ch}
|
|
onClick={() => { setActiveChannel(ch); setActiveVariant("A") }}
|
|
className={`flex-1 flex items-center justify-center gap-2 py-2.5 text-xs font-bold transition-colors ${isActive ? "bg-white" : "bg-[#F9FAFB] hover:bg-white text-gray-400"}`}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" style={{ color: isActive ? m.color : undefined }} />
|
|
<span style={{ color: isActive ? m.color : undefined }}>{m.label}</span>
|
|
{isLive && <span className="w-1.5 h-1.5" style={{ backgroundColor: m.color }} />}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* A/B variant toggle */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setActiveVariant("A")}
|
|
className={`px-3 py-1.5 text-[10px] font-bold border-2 transition-colors ${activeVariant === "A" ? "border-[#111827] text-[#111827]" : "border-gray-200 text-gray-400"}`}
|
|
>
|
|
Variant A {currentTemplate && activeVariant === "A" && hasVariantB ? `(${currentTemplate.splitPercent}%)` : ""}
|
|
</button>
|
|
{hasVariantB ? (
|
|
<>
|
|
<button
|
|
onClick={() => setActiveVariant("B")}
|
|
className={`px-3 py-1.5 text-[10px] font-bold border-2 transition-colors ${activeVariant === "B" ? "border-[#1E40AF] text-[#1E40AF]" : "border-gray-200 text-gray-400"}`}
|
|
>
|
|
Variant B ({variantB.splitPercent}%)
|
|
</button>
|
|
<button
|
|
onClick={deleteVariantB}
|
|
className="text-gray-300 hover:text-[#DC2626] transition-colors ml-1"
|
|
title="Remove A/B test"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</button>
|
|
{/* A/B stats */}
|
|
{(currentTemplate?.sentCount || 0) + (variantB?.sentCount || 0) > 0 && (
|
|
<div className="ml-auto flex items-center gap-3 text-[9px]">
|
|
<span className="text-gray-500">
|
|
A: {currentTemplate?.sentCount || 0} sent → {currentTemplate?.convertedCount || 0} paid
|
|
({currentTemplate?.sentCount ? Math.round(((currentTemplate?.convertedCount || 0) / currentTemplate.sentCount) * 100) : 0}%)
|
|
</span>
|
|
<span className="text-[#1E40AF]">
|
|
B: {variantB.sentCount} sent → {variantB.convertedCount} paid
|
|
({variantB.sentCount ? Math.round((variantB.convertedCount / variantB.sentCount) * 100) : 0}%)
|
|
</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<button
|
|
onClick={createVariantB}
|
|
className="px-3 py-1.5 text-[10px] font-bold border-2 border-dashed border-gray-200 text-gray-400 hover:border-[#1E40AF] hover:text-[#1E40AF] transition-colors flex items-center gap-1"
|
|
>
|
|
<Plus className="h-3 w-3" /> A/B test
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Template name */}
|
|
<div>
|
|
<input
|
|
value={editName}
|
|
onChange={e => { setEditName(e.target.value); setDirty(true) }}
|
|
className="text-lg font-black text-[#111827] bg-transparent border-b-2 border-transparent hover:border-gray-200 focus:border-[#1E40AF] outline-none w-full pb-1 transition-colors"
|
|
placeholder="Template name..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Email subject */}
|
|
{activeChannel === "email" && (
|
|
<div>
|
|
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-wide block mb-1">Subject line</label>
|
|
<input
|
|
value={editSubject}
|
|
onChange={e => { setEditSubject(e.target.value); setDirty(true) }}
|
|
className="w-full h-10 px-3 border-2 border-gray-200 text-sm font-medium placeholder:text-gray-300 focus:border-[#1E40AF] outline-none"
|
|
placeholder="e.g. Your £{{amount}} pledge — payment details"
|
|
/>
|
|
<p className="text-[9px] text-gray-400 mt-1">Preview: {resolvePreview(editSubject || "")}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Message body editor */}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<label className="text-[10px] font-bold text-gray-500 uppercase tracking-wide">Message</label>
|
|
<span className="text-[9px] text-gray-400">{editBody.length} chars{activeChannel === "sms" ? ` · ${Math.ceil(editBody.length / 160)} SMS` : ""}</span>
|
|
</div>
|
|
<textarea
|
|
ref={editorRef}
|
|
value={editBody}
|
|
onChange={e => { setEditBody(e.target.value); setDirty(true) }}
|
|
className="w-full h-64 px-4 py-3 border-2 border-gray-200 text-sm font-mono leading-relaxed placeholder:text-gray-300 focus:border-[#1E40AF] outline-none resize-y"
|
|
placeholder="Type your message..."
|
|
/>
|
|
</div>
|
|
|
|
{/* Variable chips */}
|
|
<div>
|
|
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-wide mb-2">Insert variable</p>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{TEMPLATE_VARIABLES.map(v => (
|
|
<button
|
|
key={v.key}
|
|
onClick={() => insertVariable(v.key)}
|
|
className="px-2 py-1 text-[10px] font-mono border border-gray-200 text-gray-600 hover:border-[#1E40AF] hover:text-[#1E40AF] hover:bg-[#1E40AF]/5 transition-colors"
|
|
title={`${v.label} — e.g. "${v.example}"`}
|
|
>
|
|
{`{{${v.key}}}`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2 pt-2">
|
|
<button
|
|
onClick={saveTemplate}
|
|
disabled={saving || !dirty}
|
|
className={`px-5 py-2.5 text-xs font-bold transition-all flex items-center gap-1.5 ${saved ? "bg-[#16A34A] text-white" : dirty ? "bg-[#111827] text-white hover:bg-gray-800" : "bg-gray-100 text-gray-400 cursor-not-allowed"}`}
|
|
>
|
|
{saving ? <><Loader2 className="h-3 w-3 animate-spin" /> Saving</> : saved ? <><Check className="h-3 w-3" /> Saved</> : <><Pencil className="h-3 w-3" /> Save</>}
|
|
</button>
|
|
{dirty && (
|
|
<button
|
|
onClick={() => {
|
|
const tpl = templates.find(t => t.step === activeStep && t.channel === activeChannel && t.variant === activeVariant)
|
|
if (tpl) { setEditBody(tpl.body); setEditSubject(tpl.subject || ""); setEditName(tpl.name); setDirty(false) }
|
|
}}
|
|
className="px-3 py-2.5 text-xs text-gray-400 hover:text-gray-600 flex items-center gap-1"
|
|
>
|
|
<RotateCcw className="h-3 w-3" /> Discard
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* WhatsApp formatting help */}
|
|
{activeChannel === "whatsapp" && (
|
|
<div className="bg-[#F9FAFB] border border-gray-100 px-4 py-3">
|
|
<p className="text-[9px] font-bold text-gray-500 uppercase tracking-wide mb-1.5">WhatsApp formatting</p>
|
|
<div className="grid grid-cols-4 gap-3 text-[10px]">
|
|
<div><code className="text-[#111827] font-mono">*bold*</code><br /><span className="text-gray-400">→ <strong>bold</strong></span></div>
|
|
<div><code className="text-[#111827] font-mono">_italic_</code><br /><span className="text-gray-400">→ <em>italic</em></span></div>
|
|
<div><code className="text-[#111827] font-mono">`code`</code><br /><span className="text-gray-400">→ <code>code</code></span></div>
|
|
<div><code className="text-[#111827] font-mono">━━━</code><br /><span className="text-gray-400">→ divider</span></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Channel Strategy ── */}
|
|
<div className="bg-white border border-gray-200">
|
|
<button
|
|
onClick={() => setShowStrategy(!showStrategy)}
|
|
className="w-full px-5 py-4 flex items-center justify-between hover:bg-[#F9FAFB] transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Zap className="h-4 w-4 text-[#1E40AF]" />
|
|
<div className="text-left">
|
|
<h2 className="text-sm font-bold text-[#111827]">Channel strategy</h2>
|
|
<p className="text-[10px] text-gray-500">
|
|
{config?.strategy === "waterfall" ? "Waterfall — try WhatsApp, fall back to SMS, then Email" :
|
|
config?.strategy === "parallel" ? "Belt & suspenders — send via multiple channels" :
|
|
config?.strategy === "escalation" ? "Escalation — add channels as urgency increases" :
|
|
"Custom channel routing"}
|
|
{activeChannels.length > 0 && ` · ${activeChannels.map(c => CH_META[c].label).join(", ")} active`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<ChevronDown className={`h-4 w-4 text-gray-300 transition-transform ${showStrategy ? "rotate-180" : ""}`} />
|
|
</button>
|
|
|
|
{showStrategy && (
|
|
<div className="px-5 pb-5 space-y-5">
|
|
{/* Strategy presets */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-px bg-gray-200">
|
|
{STRATEGY_PRESETS.map(s => (
|
|
<button
|
|
key={s.id}
|
|
onClick={() => saveStrategy(s.id, s.matrix)}
|
|
className={`p-4 text-left transition-colors ${config?.strategy === s.id ? "bg-[#1E40AF]/5 ring-2 ring-inset ring-[#1E40AF]" : "bg-white hover:bg-[#F9FAFB]"}`}
|
|
>
|
|
<p className={`text-sm font-bold ${config?.strategy === s.id ? "text-[#1E40AF]" : "text-[#111827]"}`}>{s.name}</p>
|
|
<p className="text-[10px] text-gray-500 mt-1">{s.desc}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Channel matrix — visual */}
|
|
<div>
|
|
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-wide mb-3">How each step is delivered</p>
|
|
<div className="border border-gray-200">
|
|
{/* Header */}
|
|
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-px bg-gray-200">
|
|
<div className="bg-[#F9FAFB] px-3 py-2 text-[9px] font-bold text-gray-500 uppercase">Step</div>
|
|
{CHANNELS.map(ch => (
|
|
<div key={ch} className="bg-[#F9FAFB] px-3 py-2 text-center">
|
|
<span className="text-[9px] font-bold" style={{ color: CH_META[ch].color }}>{CH_META[ch].label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* Rows */}
|
|
{STEP_META.map((s, i) => {
|
|
const matrix = config?.channelMatrix as Record<string, string[]> | null
|
|
const stepChannels = matrix?.[String(i)] || ["whatsapp", "sms", "email"]
|
|
const isParallel = stepChannels.some(c => c.includes("+"))
|
|
const flatChannels = stepChannels.flatMap(c => c.split("+"))
|
|
|
|
return (
|
|
<div key={i} className="grid grid-cols-[1fr_80px_80px_80px] gap-px bg-gray-200">
|
|
<div className="bg-white px-3 py-2.5 flex items-center gap-2">
|
|
<span className="text-xs">{s.icon}</span>
|
|
<span className="text-xs font-bold text-[#111827]">{s.label}</span>
|
|
</div>
|
|
{CHANNELS.map(ch => {
|
|
const included = flatChannels.includes(ch)
|
|
const isFirst = flatChannels[0] === ch
|
|
return (
|
|
<div key={ch} className={`bg-white flex items-center justify-center py-2.5 ${included ? "" : "opacity-30"}`}>
|
|
{included ? (
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-5 h-5 flex items-center justify-center" style={{ backgroundColor: CH_META[ch].color + "15" }}>
|
|
<Check className="h-3 w-3" style={{ color: CH_META[ch].color }} />
|
|
</div>
|
|
{isParallel && included ? (
|
|
<span className="text-[7px] font-bold text-gray-400">+</span>
|
|
) : isFirst && !isParallel ? (
|
|
<span className="text-[7px] font-bold text-gray-400">1st</span>
|
|
) : !isParallel ? (
|
|
<span className="text-[7px] text-gray-300">fb</span>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<X className="h-3 w-3 text-gray-200" />
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
<p className="text-[9px] text-gray-400 mt-2 flex items-center gap-3">
|
|
<span><strong className="text-gray-600">1st</strong> = primary channel</span>
|
|
<span><strong className="text-gray-600">fb</strong> = fallback if primary fails</span>
|
|
<span><strong className="text-gray-600">+</strong> = sent simultaneously</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Live Feed (condensed) ── */}
|
|
<div className="bg-white border border-gray-200">
|
|
<button
|
|
onClick={() => setShowFeed(!showFeed)}
|
|
className="w-full px-5 py-3 flex items-center justify-between hover:bg-[#F9FAFB] transition-colors"
|
|
>
|
|
<h2 className="text-sm font-bold text-[#111827] flex items-center gap-2">
|
|
<Send className="h-4 w-4 text-gray-400" />
|
|
Live feed
|
|
{pending.length > 0 && <span className="text-[8px] font-bold bg-[#F59E0B]/10 text-[#F59E0B] px-1.5 py-0.5">{pending.length} scheduled</span>}
|
|
{stats && stats.total > 0 && <span className="text-[8px] font-bold bg-[#16A34A]/10 text-[#16A34A] px-1.5 py-0.5">{stats.total} this week</span>}
|
|
</h2>
|
|
<ChevronDown className={`h-4 w-4 text-gray-300 transition-transform ${showFeed ? "rotate-180" : ""}`} />
|
|
</button>
|
|
|
|
{showFeed && (
|
|
<div className="border-t border-gray-100">
|
|
{/* Stats bar */}
|
|
{stats && stats.total > 0 && (
|
|
<div className="grid grid-cols-5 gap-px bg-gray-100 border-b border-gray-100">
|
|
{[
|
|
{ v: stats.whatsapp.sent, l: "WhatsApp", c: "#25D366" },
|
|
{ v: stats.email.sent, l: "Email", c: "#1E40AF" },
|
|
{ v: stats.sms.sent, l: "SMS", c: "#F59E0B" },
|
|
{ v: stats.total, l: "Total", c: "#111827" },
|
|
{ v: `${stats.deliveryRate}%`, l: "Delivered", c: stats.deliveryRate >= 90 ? "#16A34A" : "#F59E0B" },
|
|
].map(s => (
|
|
<div key={s.l} className="bg-white px-3 py-2.5 text-center">
|
|
<p className="text-lg font-black" style={{ color: s.c }}>{s.v}</p>
|
|
<p className="text-[8px] text-gray-400">{s.l}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Scheduled */}
|
|
{pending.length > 0 && (
|
|
<div className="border-b border-gray-100 px-5 py-3">
|
|
<p className="text-[9px] font-bold text-gray-400 uppercase tracking-wide mb-2 flex items-center gap-1"><Clock className="h-3 w-3" /> Upcoming</p>
|
|
<div className="space-y-1">
|
|
{pending.slice(0, 5).map(r => {
|
|
const when = new Date(r.scheduledAt)
|
|
const isToday = when.toDateString() === new Date().toDateString()
|
|
const label = isToday ? "Today" : when.toLocaleDateString("en-GB", { day: "numeric", month: "short" })
|
|
return (
|
|
<div key={r.id} className="flex items-center gap-2 text-xs">
|
|
<div className={`w-1.5 h-1.5 shrink-0 ${isToday ? "bg-[#F59E0B]" : "bg-gray-300"}`} />
|
|
<span className="flex-1 truncate"><strong>{r.donorName || "Anonymous"}</strong> · £{(r.amountPence / 100).toFixed(0)} · {STEP_META[r.step]?.label || `Step ${r.step}`}</span>
|
|
<span className={`text-[10px] font-bold shrink-0 ${isToday ? "text-[#F59E0B]" : "text-gray-400"}`}>{label}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent messages */}
|
|
<div className="max-h-[300px] overflow-y-auto">
|
|
{history.length === 0 ? (
|
|
<div className="p-6 text-center text-xs text-gray-400">No messages sent yet. They'll appear here as donors pledge.</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-50">
|
|
{history.map(msg => {
|
|
const cm = CH_META[msg.channel as Channel]
|
|
const Icon = cm?.icon || Mail
|
|
const color = cm?.color || "#999"
|
|
return (
|
|
<div key={msg.id} className="px-5 py-2 flex items-center gap-2.5">
|
|
<div className="w-5 h-5 flex items-center justify-center shrink-0" style={{ backgroundColor: color + "15" }}>
|
|
<Icon className="h-3 w-3" style={{ color }} />
|
|
</div>
|
|
<div className="flex-1 min-w-0 text-xs truncate">
|
|
<strong>{msg.donorName || "Anonymous"}</strong>
|
|
<span className="text-gray-400 mx-1">·</span>
|
|
{STEP_META.find(s => `reminder_${s.step}` === msg.messageType || (s.step === 0 && msg.messageType === "receipt"))?.label || msg.messageType}
|
|
</div>
|
|
{msg.success ? <Check className="h-3 w-3 text-[#16A34A] shrink-0" /> : <span title={msg.error || "Failed"}><X className="h-3 w-3 text-[#DC2626] shrink-0" /></span>}
|
|
<span className="text-[9px] text-gray-400 shrink-0">{getTimeAgo(new Date(msg.createdAt))}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
|
|
// ─── Phone Mockup — WhatsApp-native ─────────────────────────
|
|
//
|
|
// This is THE feature. Aaisha sees exactly what Ahmed sees.
|
|
// Green bubbles, blue ticks, org name in header.
|
|
// Switches to email/SMS preview based on channel.
|
|
|
|
function PhoneMockup({ channel, body, subject, orgName }: {
|
|
channel: Channel; body: string; subject: string; orgName: string
|
|
}) {
|
|
const preview = resolvePreview(body)
|
|
|
|
if (channel === "whatsapp") return <WhatsAppPreview text={preview} orgName={orgName} />
|
|
if (channel === "email") return <EmailPreview text={preview} subject={resolvePreview(subject)} orgName={orgName} />
|
|
return <SmsPreview text={preview} orgName={orgName} />
|
|
}
|
|
|
|
function WhatsAppPreview({ text, orgName }: { text: string; orgName: string }) {
|
|
// Parse WhatsApp formatting: *bold*, _italic_, `code`, line breaks
|
|
const formatWA = (raw: string) => {
|
|
return raw.split("\n").map((line, i) => {
|
|
// Full-width divider
|
|
if (/^[━─═]{3,}$/.test(line.trim())) {
|
|
return <div key={i} className="border-t border-[#25D366]/20 my-1" />
|
|
}
|
|
|
|
const parts = line.split(/(\*[^*]+\*|_[^_]+_|`[^`]+`)/)
|
|
return (
|
|
<div key={i} className={line.trim() === "" ? "h-2" : ""}>
|
|
{parts.map((p, j) => {
|
|
if (p.startsWith("*") && p.endsWith("*")) return <strong key={j} className="font-bold">{p.slice(1, -1)}</strong>
|
|
if (p.startsWith("_") && p.endsWith("_")) return <em key={j} className="italic">{p.slice(1, -1)}</em>
|
|
if (p.startsWith("`") && p.endsWith("`")) return <code key={j} className="bg-black/5 px-1 py-0.5 rounded text-[10px] font-mono">{p.slice(1, -1)}</code>
|
|
return <span key={j}>{p}</span>
|
|
})}
|
|
</div>
|
|
)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<div className="w-[300px] border border-gray-300 shadow-lg overflow-hidden flex flex-col" style={{ borderRadius: "24px" }}>
|
|
{/* Status bar */}
|
|
<div className="bg-[#075E54] px-4 py-1.5 flex items-center justify-between">
|
|
<span className="text-[9px] text-white/70 font-medium">9:41</span>
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="flex items-center gap-0.5">
|
|
{[4, 6, 8, 10].map(h => <div key={h} className="w-[3px] bg-white/70" style={{ height: h, borderRadius: 1 }} />)}
|
|
</div>
|
|
<div className="w-5 h-2.5 border border-white/70 flex items-center" style={{ borderRadius: 2 }}>
|
|
<div className="w-3.5 h-1.5 bg-white/70 ml-0.5" style={{ borderRadius: 1 }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* WhatsApp header */}
|
|
<div className="bg-[#075E54] px-3 pb-2.5 flex items-center gap-2.5">
|
|
<div className="text-white/50 text-xs">←</div>
|
|
<div className="w-8 h-8 bg-[#128C7E] flex items-center justify-center" style={{ borderRadius: "50%" }}>
|
|
<span className="text-white text-[10px] font-bold">{orgName[0]?.toUpperCase()}</span>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-white text-[13px] font-medium truncate">{orgName}</p>
|
|
<p className="text-[9px] text-white/60">online</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chat area */}
|
|
<div className="bg-[#ECE5DD] flex-1 px-3 py-3 min-h-[280px] max-h-[400px] overflow-y-auto" style={{
|
|
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23000' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
|
|
}}>
|
|
{/* Green bubble (outgoing) */}
|
|
<div className="flex justify-end">
|
|
<div className="bg-[#DCF8C6] max-w-[85%] px-3 py-2 text-[12px] leading-[1.45] text-[#303030] relative shadow-sm" style={{ borderRadius: "8px 0 8px 8px" }}>
|
|
{formatWA(text)}
|
|
<div className="flex items-center justify-end gap-1 mt-1 -mb-0.5">
|
|
<span className="text-[9px] text-[#667781]">
|
|
{new Date().toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}
|
|
</span>
|
|
<CheckCheck className="h-3 w-3 text-[#53BDEB]" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Input bar */}
|
|
<div className="bg-[#F0F0F0] px-3 py-2 flex items-center gap-2">
|
|
<div className="flex-1 bg-white px-3 py-1.5 text-[11px] text-gray-400" style={{ borderRadius: "20px" }}>Type a message</div>
|
|
<div className="w-8 h-8 bg-[#075E54] flex items-center justify-center" style={{ borderRadius: "50%" }}>
|
|
<Send className="h-3.5 w-3.5 text-white ml-0.5" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EmailPreview({ text, subject, orgName }: { text: string; subject: string; orgName: string }) {
|
|
return (
|
|
<div className="w-[300px] border border-gray-300 shadow-lg overflow-hidden flex flex-col" style={{ borderRadius: "12px" }}>
|
|
{/* Email client chrome */}
|
|
<div className="bg-[#F3F4F6] px-4 py-2 border-b border-gray-200 flex items-center gap-2">
|
|
<div className="flex gap-1.5">
|
|
<div className="w-2.5 h-2.5 bg-[#EF4444]" style={{ borderRadius: "50%" }} />
|
|
<div className="w-2.5 h-2.5 bg-[#F59E0B]" style={{ borderRadius: "50%" }} />
|
|
<div className="w-2.5 h-2.5 bg-[#22C55E]" style={{ borderRadius: "50%" }} />
|
|
</div>
|
|
<span className="text-[9px] text-gray-400 ml-2">Inbox</span>
|
|
</div>
|
|
|
|
{/* Email header */}
|
|
<div className="bg-white px-4 py-3 border-b border-gray-100 space-y-1">
|
|
<p className="text-[13px] font-bold text-[#111827] leading-tight">{subject || "No subject"}</p>
|
|
<div className="flex items-center gap-2 text-[10px] text-gray-500">
|
|
<div className="w-5 h-5 bg-[#1E40AF] flex items-center justify-center" style={{ borderRadius: "50%" }}>
|
|
<span className="text-white text-[8px] font-bold">{orgName[0]?.toUpperCase()}</span>
|
|
</div>
|
|
<div>
|
|
<span className="font-medium text-[#111827]">{orgName}</span>
|
|
<span className="text-gray-400 ml-1">to me</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Email body */}
|
|
<div className="bg-white px-4 py-3 text-[11px] leading-[1.6] text-[#374151] min-h-[240px] max-h-[360px] overflow-y-auto whitespace-pre-wrap">
|
|
{text}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SmsPreview({ text, orgName }: { text: string; orgName: string }) {
|
|
return (
|
|
<div className="w-[300px] border border-gray-300 shadow-lg overflow-hidden flex flex-col" style={{ borderRadius: "24px" }}>
|
|
{/* Status bar */}
|
|
<div className="bg-[#F2F2F7] px-4 py-1.5 flex items-center justify-between">
|
|
<span className="text-[9px] text-[#111827] font-medium">9:41</span>
|
|
<div className="flex items-center gap-1">
|
|
<div className="w-4 h-2.5 border border-gray-400 flex items-center" style={{ borderRadius: 2 }}>
|
|
<div className="w-2.5 h-1.5 bg-gray-400 ml-0.5" style={{ borderRadius: 1 }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* SMS header */}
|
|
<div className="bg-[#F2F2F7] px-4 pb-2 text-center">
|
|
<div className="w-10 h-10 bg-gray-300 mx-auto mb-1 flex items-center justify-center" style={{ borderRadius: "50%" }}>
|
|
<span className="text-white text-sm font-bold">{orgName[0]?.toUpperCase()}</span>
|
|
</div>
|
|
<p className="text-[13px] font-medium text-[#111827]">{orgName}</p>
|
|
<p className="text-[10px] text-gray-400">Text Message</p>
|
|
</div>
|
|
|
|
{/* Chat area */}
|
|
<div className="bg-white flex-1 px-3 py-4 min-h-[220px] max-h-[340px] overflow-y-auto">
|
|
<div className="flex justify-start">
|
|
<div className="bg-[#E5E5EA] max-w-[85%] px-3 py-2 text-[12px] leading-[1.4] text-[#111827]" style={{ borderRadius: "16px 16px 16px 4px" }}>
|
|
{text}
|
|
</div>
|
|
</div>
|
|
<p className="text-[9px] text-gray-400 mt-1.5 text-center">
|
|
{new Date().toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Input bar */}
|
|
<div className="bg-white px-3 py-2 border-t border-gray-100 flex items-center gap-2">
|
|
<div className="flex-1 bg-[#F2F2F7] px-3 py-1.5 text-[11px] text-gray-400" style={{ borderRadius: "20px" }}>Text Message</div>
|
|
<div className="w-7 h-7 bg-[#007AFF] flex items-center justify-center" style={{ borderRadius: "50%" }}>
|
|
<span className="text-white text-[10px]">↑</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────
|
|
|
|
function getTimeAgo(date: Date): string {
|
|
const s = Math.floor((Date.now() - date.getTime()) / 1000)
|
|
if (s < 60) return "now"
|
|
if (s < 3600) return `${Math.floor(s / 60)}m`
|
|
if (s < 86400) return `${Math.floor(s / 3600)}h`
|
|
if (s < 604800) return `${Math.floor(s / 86400)}d`
|
|
return date.toLocaleDateString("en-GB", { day: "numeric", month: "short" })
|
|
}
|