mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +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>
237 lines
7.8 KiB
TypeScript
237 lines
7.8 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { createClient } from '@/lib/supabase/client';
|
|
import { MessageTemplate } from '@/types';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog';
|
|
import { ArrowLeft, Send, Loader2, Users, Save } from 'lucide-react';
|
|
|
|
interface AudienceConfig {
|
|
type: string;
|
|
tagIds?: string[];
|
|
csvContacts?: { phone: string; name?: string }[];
|
|
}
|
|
|
|
interface Step4Props {
|
|
name: string;
|
|
onNameChange: (name: string) => void;
|
|
template: MessageTemplate;
|
|
audience: AudienceConfig;
|
|
onSend: () => void;
|
|
onSaveDraft?: () => void;
|
|
onBack: () => void;
|
|
isProcessing: boolean;
|
|
progress: number;
|
|
}
|
|
|
|
export function Step4ScheduleSend({
|
|
name,
|
|
onNameChange,
|
|
template,
|
|
audience,
|
|
onSend,
|
|
onSaveDraft,
|
|
onBack,
|
|
isProcessing,
|
|
progress,
|
|
}: Step4Props) {
|
|
const [showConfirm, setShowConfirm] = useState(false);
|
|
const [estimatedReach, setEstimatedReach] = useState<number>(0);
|
|
const [loadingReach, setLoadingReach] = useState(true);
|
|
|
|
useEffect(() => {
|
|
async function calculateReach() {
|
|
setLoadingReach(true);
|
|
try {
|
|
const supabase = createClient();
|
|
|
|
if (audience.type === 'all') {
|
|
const { count } = await supabase
|
|
.from('contacts')
|
|
.select('*', { count: 'exact', head: true });
|
|
setEstimatedReach(count ?? 0);
|
|
} else if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) {
|
|
const { data: contactTags } = await supabase
|
|
.from('contact_tags')
|
|
.select('contact_id')
|
|
.in('tag_id', audience.tagIds);
|
|
|
|
const uniqueIds = new Set((contactTags ?? []).map((ct) => ct.contact_id));
|
|
setEstimatedReach(uniqueIds.size);
|
|
} else if (audience.type === 'csv' && audience.csvContacts) {
|
|
setEstimatedReach(audience.csvContacts.length);
|
|
} else {
|
|
setEstimatedReach(0);
|
|
}
|
|
} finally {
|
|
setLoadingReach(false);
|
|
}
|
|
}
|
|
|
|
calculateReach();
|
|
}, [audience]);
|
|
|
|
const audienceLabel =
|
|
audience.type === 'all'
|
|
? 'All Contacts'
|
|
: audience.type === 'tags'
|
|
? `Tags (${audience.tagIds?.length ?? 0} selected)`
|
|
: audience.type === 'csv'
|
|
? 'CSV Upload'
|
|
: 'Custom';
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-foreground">Review & Send</h2>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Name your broadcast, review the details, and send.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Broadcast Name */}
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-foreground">Broadcast Name</label>
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => onNameChange(e.target.value)}
|
|
placeholder="e.g. Summer Sale Announcement"
|
|
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
|
|
/>
|
|
</div>
|
|
|
|
{/* Summary Card */}
|
|
<div className="rounded-xl border border-border bg-card/50 p-4 space-y-3">
|
|
<p className="text-sm font-medium text-foreground">Summary</p>
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Template</p>
|
|
<p className="text-foreground">{template.name}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Audience</p>
|
|
<p className="text-foreground">{audienceLabel}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Estimated Reach</p>
|
|
<div className="flex items-center gap-1.5">
|
|
{loadingReach ? (
|
|
<Loader2 className="h-3 w-3 animate-spin text-primary" />
|
|
) : (
|
|
<>
|
|
<Users className="h-3.5 w-3.5 text-primary" />
|
|
<p className="font-medium text-foreground">{estimatedReach.toLocaleString()}</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-muted-foreground">Language</p>
|
|
<p className="text-foreground">{template.language ?? 'en_US'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Processing overlay */}
|
|
{isProcessing && (
|
|
<div className="rounded-xl border border-primary/20 bg-primary/5 p-4">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
|
<p className="text-sm font-medium text-foreground">Sending broadcast...</p>
|
|
</div>
|
|
<span className="text-xs font-medium text-primary">{progress}%</span>
|
|
</div>
|
|
<div className="h-1.5 w-full rounded-full bg-muted">
|
|
<div
|
|
className="h-1.5 rounded-full bg-primary transition-all duration-300"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-4">
|
|
<Button
|
|
variant="outline"
|
|
onClick={onBack}
|
|
disabled={isProcessing}
|
|
className="border-border text-muted-foreground"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Back
|
|
</Button>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{onSaveDraft && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={onSaveDraft}
|
|
disabled={!name.trim() || isProcessing}
|
|
className="border-border text-muted-foreground hover:bg-muted disabled:opacity-50"
|
|
>
|
|
<Save className="h-4 w-4" />
|
|
Save as Draft
|
|
</Button>
|
|
)}
|
|
|
|
<Dialog open={showConfirm} onOpenChange={setShowConfirm}>
|
|
<DialogTrigger
|
|
render={
|
|
<Button
|
|
disabled={!name.trim() || isProcessing}
|
|
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
/>
|
|
}
|
|
>
|
|
<Send className="h-4 w-4" />
|
|
Send Broadcast
|
|
</DialogTrigger>
|
|
<DialogContent className="border-border bg-popover sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-popover-foreground">Confirm Broadcast</DialogTitle>
|
|
<DialogDescription className="text-muted-foreground">
|
|
You are about to send this broadcast to{' '}
|
|
<span className="font-medium text-popover-foreground">{estimatedReach.toLocaleString()}</span>{' '}
|
|
contacts using the{' '}
|
|
<span className="font-medium text-popover-foreground">{template.name}</span> template.
|
|
This action cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowConfirm(false)}
|
|
className="border-border text-muted-foreground"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
setShowConfirm(false);
|
|
onSend();
|
|
}}
|
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
|
>
|
|
<Send className="h-4 w-4" />
|
|
Confirm & Send
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|