wacrm/src/app/(dashboard)/broadcasts/new/page.tsx
Arnas Donauskas 77dfa4f165
chore: switch accent palette to Hostinger violet, drop emerald green
The legal brief on third-party trademark use says: avoid the WhatsApp
teal/green palette and any visual treatment that could read as a
WhatsApp/Meta product. Emerald-500 was used as the primary brand colour
across hero, CTAs, sidebar, charts, badges, and the favicon — close
enough in hue to WhatsApp's signature green to muddy the line.

Swapping to Tailwind violet (which matches the Hostinger brand purple
#7c3aed) makes the visual identity unambiguously ours.

Scope:
- Mechanical Tailwind class swap: emerald-* → violet-* across src/.
- src/app/globals.css: --primary, --ring, --sidebar-primary, --sidebar-ring,
  --chart-1 oklch tokens swapped from emerald (oklch 0.696 0.17 162) to
  Hostinger-aligned violet (oklch 0.526 0.247 293).
- src/app/icon.tsx: favicon background #10b981 → #7c3aed.
- src/app/opengraph-image.tsx: badge bg, accent text, gradient.
- src/components/landing/hero.tsx + cta-banner.tsx: radial-gradient hex.
- src/components/landing/mock/analytics-mock.tsx: marketing chart line.
- src/components/dashboard/conversations-chart.tsx + response-time-chart.tsx:
  "Outgoing" series colour. Distinguishable from "Incoming" blue at a
  glance; we can revisit the chart pair later for stronger colourblind
  contrast (separate concern).
- README.md: license badge colour parameter.

Out of scope:
- src/components/settings/tag-manager.tsx still offers "Emerald" as one
  of the user-pickable contact-tag colours alongside Blue / Yellow / Red
  / etc. — that's a user data choice, not a brand mark.

Contrast check: white text on #7c3aed measures ~4.78:1 (WCAG AA passing
for normal body text). Violet-400 (#a78bfa) on slate-950 measures ~10:1
for accent text, well clear of AA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:57:12 +03:00

223 lines
7.3 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { toast } from 'sonner';
import { MessageTemplate } from '@/types';
import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template';
import { Step2SelectAudience } from '@/components/broadcasts/step2-select-audience';
import { Step3Personalize } from '@/components/broadcasts/step3-personalize';
import { Step4ScheduleSend } from '@/components/broadcasts/step4-schedule-send';
import { useBroadcastSending } from '@/hooks/use-broadcast-sending';
import { Check } from 'lucide-react';
const steps = [
{ label: 'Template', key: 'template' },
{ label: 'Audience', key: 'audience' },
{ label: 'Personalize', key: 'personalize' },
{ label: 'Send', key: 'send' },
] as const;
export default function NewBroadcastPage() {
const router = useRouter();
const { createAndSendBroadcast, isProcessing, progress } = useBroadcastSending();
const [currentStep, setCurrentStep] = useState(0);
const [template, setTemplate] = useState<MessageTemplate | null>(null);
const [audience, setAudience] = useState<{
type: 'all' | 'tags' | 'custom_field' | 'csv';
tagIds?: string[];
customField?: {
fieldId: string;
operator: 'is' | 'is_not' | 'contains';
value: string;
};
csvContacts?: { phone: string; name?: string }[];
excludeTagIds?: string[];
}>({ type: 'all' });
const [variables, setVariables] = useState<
Record<string, { type: 'static' | 'field' | 'custom_field'; value: string }>
>({});
const [name, setName] = useState('');
async function handleSend() {
if (!template) return;
try {
const broadcastId = await createAndSendBroadcast({
name,
template,
audience: {
type: audience.type,
tagIds: audience.tagIds,
customField: audience.customField,
csvContacts: audience.csvContacts,
excludeTagIds: audience.excludeTagIds,
},
variables,
});
router.push(`/broadcasts/${broadcastId}`);
} catch (err) {
// Previously swallowed with console.error — the wizard would
// just no-op, leaving the user confused. Surface the reason.
const message = err instanceof Error ? err.message : 'Broadcast failed';
console.error('Broadcast failed:', err);
toast.error(message);
}
}
/**
* Writes a draft broadcast row — no recipients, no sending. The user
* can revisit it via the list page to finish the flow later. We
* don't persist the in-progress audience/variable config here
* because the current schema doesn't carry it past `audience_filter`
* and `template_variables`; those are enough for the user to
* recognize the draft but not to exactly round-trip into the wizard.
* A full resume-draft UX is a future polish.
*/
async function handleSaveDraft() {
if (!template || !name.trim()) {
toast.error('Give the broadcast a name before saving a draft.');
return;
}
const supabase = createClient();
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) {
toast.error('Not signed in.');
return;
}
const { error } = await supabase.from('broadcasts').insert({
user_id: user.id,
name: name.trim(),
template_name: template.name,
template_language: template.language ?? 'en_US',
template_variables: variables,
audience_filter: {
type: audience.type,
tagIds: audience.tagIds,
},
status: 'draft',
total_recipients: 0,
sent_count: 0,
delivered_count: 0,
read_count: 0,
replied_count: 0,
failed_count: 0,
});
if (error) {
toast.error(`Failed to save draft: ${error.message}`);
return;
}
toast.success('Draft saved');
router.push('/broadcasts');
}
return (
<div className="mx-auto max-w-3xl space-y-8">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white">New Broadcast</h1>
<p className="mt-1 text-sm text-slate-400">
Create and send a broadcast message to your contacts.
</p>
</div>
{/* Step Indicator */}
<div className="flex items-center justify-between">
{steps.map((step, index) => {
const isActive = index === currentStep;
const isCompleted = index < currentStep;
return (
<div key={step.key} className="flex flex-1 items-center">
<div className="flex items-center gap-2">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-medium transition-all ${
isCompleted
? 'bg-violet-500 text-white'
: isActive
? 'border-2 border-violet-500 bg-violet-500/10 text-violet-400'
: 'border border-slate-700 bg-slate-800 text-slate-500'
}`}
>
{isCompleted ? <Check className="h-4 w-4" /> : index + 1}
</div>
<span
className={`hidden text-sm font-medium sm:block ${
isActive ? 'text-white' : isCompleted ? 'text-violet-400' : 'text-slate-500'
}`}
>
{step.label}
</span>
</div>
{index < steps.length - 1 && (
<div
className={`mx-3 h-px flex-1 ${
index < currentStep ? 'bg-violet-500' : 'bg-slate-800'
}`}
/>
)}
</div>
);
})}
</div>
{/* Step Content */}
<div className="relative min-h-[400px]">
<div
className="transition-all duration-300 ease-in-out"
style={{
opacity: isProcessing ? 0.6 : 1,
pointerEvents: isProcessing ? 'none' : 'auto',
}}
>
{currentStep === 0 && (
<Step1ChooseTemplate
selectedTemplate={template}
onSelect={setTemplate}
onNext={() => setCurrentStep(1)}
onBack={() => router.push('/broadcasts')}
/>
)}
{currentStep === 1 && (
<Step2SelectAudience
audience={audience}
onUpdate={setAudience}
onNext={() => setCurrentStep(2)}
onBack={() => setCurrentStep(0)}
/>
)}
{currentStep === 2 && template && (
<Step3Personalize
template={template}
variables={variables}
onUpdate={setVariables}
onNext={() => setCurrentStep(3)}
onBack={() => setCurrentStep(1)}
/>
)}
{currentStep === 3 && template && (
<Step4ScheduleSend
name={name}
onNameChange={setName}
template={template}
audience={audience}
onSend={handleSend}
onSaveDraft={handleSaveDraft}
onBack={() => setCurrentStep(2)}
isProcessing={isProcessing}
progress={progress}
/>
)}
</div>
</div>
</div>
);
}