mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Introduce a light/dark MODE axis orthogonal to the existing accent themes. Neutral surface tokens now live in html[data-mode="light|dark"] blocks; accent tokens (--primary*, --ring, --chart-1, --sidebar-primary*) stay in html[data-theme] blocks, so any accent works in either mode. - globals.css: split neutrals (mode) from accent (theme); add light palette; dark values unchanged (dark stays the default). - lib/themes.ts: Mode type, DEFAULT_MODE=dark, MODE_STORAGE_KEY, isMode. - use-theme.tsx: provider owns mode/setMode/toggleMode + cross-tab sync. - layout.tsx: boot script applies persisted mode before first paint (no flash); colorScheme "dark light". - mode-toggle.tsx (header sun/moon) + themed-toaster.tsx (mode-aware). - appearance-panel.tsx: light/dark control + updated copy. - Tokenize hardcoded neutral colors (slate/white/gray) -> semantic tokens across the app so every surface flips with mode. Flow canvas and message-bubble timestamps made mode-aware too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
230 lines
7.5 KiB
TypeScript
230 lines
7.5 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { createClient } from '@/lib/supabase/client';
|
|
import { useAuth } from '@/hooks/use-auth';
|
|
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 { accountId } = useAuth();
|
|
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;
|
|
}
|
|
if (!accountId) {
|
|
toast.error('Your profile is not linked to an account.');
|
|
return;
|
|
}
|
|
|
|
const { error } = await supabase.from('broadcasts').insert({
|
|
user_id: user.id,
|
|
account_id: accountId,
|
|
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-foreground">New Broadcast</h1>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
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-primary text-primary-foreground'
|
|
: isActive
|
|
? 'border-2 border-primary bg-primary/10 text-primary'
|
|
: 'border border-border bg-muted text-muted-foreground'
|
|
}`}
|
|
>
|
|
{isCompleted ? <Check className="h-4 w-4" /> : index + 1}
|
|
</div>
|
|
<span
|
|
className={`hidden text-sm font-medium sm:block ${
|
|
isActive ? 'text-foreground' : isCompleted ? 'text-primary' : 'text-muted-foreground'
|
|
}`}
|
|
>
|
|
{step.label}
|
|
</span>
|
|
</div>
|
|
{index < steps.length - 1 && (
|
|
<div
|
|
className={`mx-3 h-px flex-1 ${
|
|
index < currentStep ? 'bg-primary' : 'bg-muted'
|
|
}`}
|
|
/>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|