wacrm/src/components/pipelines/deal-form.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

476 lines
16 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { createClient } from "@/lib/supabase/client";
import type {
Contact,
Conversation,
Deal,
DealStatus,
PipelineStage,
Profile,
} from "@/types";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Check,
X,
Trash2,
MessageSquare,
DollarSign,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
interface DealFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
deal?: Deal | null;
pipelineId: string;
stages: PipelineStage[];
defaultStageId?: string;
onSaved: () => void;
}
export function DealForm({
open,
onOpenChange,
deal,
pipelineId,
stages,
defaultStageId,
onSaved,
}: DealFormProps) {
const supabase = createClient();
const [title, setTitle] = useState("");
const [value, setValue] = useState("");
const [currency, setCurrency] = useState("USD");
const [contactId, setContactId] = useState("");
const [stageId, setStageId] = useState("");
const [assignedTo, setAssignedTo] = useState("");
const [expectedCloseDate, setExpectedCloseDate] = useState("");
const [notes, setNotes] = useState("");
const [contacts, setContacts] = useState<Contact[]>([]);
const [profiles, setProfiles] = useState<Profile[]>([]);
const [linkedConversation, setLinkedConversation] =
useState<Conversation | null>(null);
const [saving, setSaving] = useState(false);
const [statusAction, setStatusAction] = useState<DealStatus | null>(null);
const [deleting, setDeleting] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
// Reset the form fields every time the sheet opens or its input
// props change. This is a legitimate prop-driven sync; the rule is
// over-cautious here, hence the block-level disable.
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (!open) return;
setConfirmDelete(false);
if (deal) {
setTitle(deal.title);
setValue(String(deal.value ?? ""));
setCurrency(deal.currency || "USD");
// contact_id is nullable when the contact has been deleted
// (migration 004: ON DELETE SET NULL). "" means "no selection".
setContactId(deal.contact_id ?? "");
setStageId(deal.stage_id);
setAssignedTo(deal.assigned_to ?? "");
setExpectedCloseDate(deal.expected_close_date ?? "");
setNotes(deal.notes ?? "");
} else {
setTitle("");
setValue("");
setCurrency("USD");
setContactId("");
setStageId(defaultStageId || stages[0]?.id || "");
setAssignedTo("");
setExpectedCloseDate("");
setNotes("");
}
}, [open, deal, defaultStageId, stages]);
/* eslint-enable react-hooks/set-state-in-effect */
// Load supporting data once the sheet is open
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
const [c, p] = await Promise.all([
supabase.from("contacts").select("*").order("name"),
supabase.from("profiles").select("*").order("full_name"),
]);
if (cancelled) return;
setContacts((c.data ?? []) as Contact[]);
setProfiles((p.data ?? []) as Profile[]);
})();
return () => {
cancelled = true;
};
}, [open, supabase]);
// Fetch linked conversation for the selected contact (newest open one).
// Clearing on no-selection is sync with prop state; the populated
// case runs setLinkedConversation inside the async fetch callback.
useEffect(() => {
if (!open || !contactId) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLinkedConversation(null);
return;
}
let cancelled = false;
(async () => {
const { data } = await supabase
.from("conversations")
.select("*")
.eq("contact_id", contactId)
.order("last_message_at", { ascending: false })
.limit(1)
.maybeSingle();
if (cancelled) return;
setLinkedConversation((data as Conversation | null) ?? null);
})();
return () => {
cancelled = true;
};
}, [open, contactId, supabase]);
async function handleSave() {
if (!title.trim() || !contactId || !stageId) {
toast.error("Title, contact, and stage are required");
return;
}
setSaving(true);
const payload = {
title: title.trim(),
value: parseFloat(value) || 0,
currency,
contact_id: contactId,
pipeline_id: pipelineId,
stage_id: stageId,
assigned_to: assignedTo || null,
notes: notes.trim() || null,
expected_close_date: expectedCloseDate || null,
};
if (deal) {
const { error } = await supabase
.from("deals")
.update(payload)
.eq("id", deal.id);
if (error) {
toast.error("Failed to save deal");
setSaving(false);
return;
}
} else {
const {
data: { session },
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) {
toast.error("Not signed in");
setSaving(false);
return;
}
const { error } = await supabase
.from("deals")
.insert({ ...payload, user_id: user.id, status: "open" });
if (error) {
toast.error("Failed to create deal");
setSaving(false);
return;
}
}
setSaving(false);
toast.success(deal ? "Deal updated" : "Deal created");
onOpenChange(false);
onSaved();
}
async function handleStatusChange(status: DealStatus) {
if (!deal) return;
setStatusAction(status);
const { error } = await supabase
.from("deals")
.update({ status })
.eq("id", deal.id);
setStatusAction(null);
if (error) {
toast.error("Failed to update deal status");
return;
}
toast.success(
status === "won" ? "Marked as won" : status === "lost" ? "Marked as lost" : "Deal reopened",
);
onOpenChange(false);
onSaved();
}
async function handleDelete() {
if (!deal) return;
setDeleting(true);
const { error } = await supabase.from("deals").delete().eq("id", deal.id);
setDeleting(false);
if (error) {
toast.error("Failed to delete deal");
return;
}
toast.success("Deal deleted");
setConfirmDelete(false);
onOpenChange(false);
onSaved();
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-lg w-full p-0"
>
<div className="flex h-full flex-col">
<SheetHeader className="border-b border-slate-700/50 p-4">
<SheetTitle className="text-white">
{deal ? "Edit Deal" : "New Deal"}
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="grid gap-2">
<Label className="text-slate-300">Title</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Deal title"
className="border-slate-700 bg-slate-800 text-white"
/>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Contact</Label>
<select
value={contactId}
onChange={(e) => setContactId(e.target.value)}
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500"
>
<option value="">Select a contact</option>
{contacts.map((c) => (
<option key={c.id} value={c.id}>
{c.name || c.phone}
</option>
))}
</select>
{linkedConversation && (
<Link
href="/inbox"
className="mt-1 inline-flex items-center gap-1.5 self-start rounded-md bg-violet-500/10 px-2 py-1 text-xs text-violet-400 hover:bg-violet-500/20"
>
<MessageSquare className="h-3 w-3" />
Link to Conversation
</Link>
)}
</div>
<div className="grid grid-cols-[1fr_110px] gap-3">
<div className="grid gap-2">
<Label className="text-slate-300">Value</Label>
<div className="relative">
<DollarSign className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
<Input
type="number"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0"
className="border-slate-700 bg-slate-800 pl-7 text-white"
/>
</div>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Currency</Label>
<select
value={currency}
onChange={(e) => setCurrency(e.target.value)}
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-violet-500"
>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
</select>
</div>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Expected Close Date</Label>
<Input
type="date"
value={expectedCloseDate}
onChange={(e) => setExpectedCloseDate(e.target.value)}
className="border-slate-700 bg-slate-800 text-white"
/>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Stage</Label>
<select
value={stageId}
onChange={(e) => setStageId(e.target.value)}
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-violet-500"
>
{stages.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Assigned To</Label>
<select
value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)}
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-violet-500"
>
<option value="">Unassigned</option>
{profiles.map((p) => (
<option key={p.id} value={p.id}>
{p.full_name || p.email}
</option>
))}
</select>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Notes</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add notes..."
className="min-h-[100px] border-slate-700 bg-slate-800 text-white"
/>
</div>
{deal && (
<div className="space-y-2 rounded-lg border border-slate-700 bg-slate-900/50 p-3">
<p className="text-xs font-medium uppercase tracking-wider text-slate-400">
Status
</p>
<div className="flex gap-2">
<Button
type="button"
onClick={() => handleStatusChange("won")}
disabled={!!statusAction || deal.status === "won"}
className="flex-1 bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-50"
>
{statusAction === "won" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-1 h-4 w-4" />
Mark as Won
</>
)}
</Button>
<Button
type="button"
onClick={() => handleStatusChange("lost")}
disabled={!!statusAction || deal.status === "lost"}
className="flex-1 bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
{statusAction === "lost" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<X className="mr-1 h-4 w-4" />
Mark as Lost
</>
)}
</Button>
</div>
{deal.status && deal.status !== "open" && (
<Button
type="button"
variant="ghost"
onClick={() => handleStatusChange("open")}
disabled={!!statusAction}
className="w-full text-slate-400 hover:text-white"
>
Reopen deal
</Button>
)}
</div>
)}
</div>
<div className="border-t border-slate-700/50 bg-slate-900/80 p-4">
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={saving || !title.trim() || !contactId || !stageId}
className="flex-1 bg-violet-600 text-white hover:bg-violet-700"
>
{saving ? "Saving..." : deal ? "Save Changes" : "Create Deal"}
</Button>
</div>
{deal &&
(confirmDelete ? (
<div className="mt-3 flex items-center justify-between gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs">
<span className="text-red-300">Delete this deal?</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => setConfirmDelete(false)}
disabled={deleting}
className="rounded px-2 py-1 text-slate-300 hover:bg-slate-800"
>
Cancel
</button>
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{deleting ? "Deleting..." : "Confirm"}
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="mt-3 flex w-full items-center justify-center gap-1 text-xs text-red-400 hover:text-red-300"
>
<Trash2 className="h-3 w-3" />
Delete Deal
</button>
))}
</div>
</div>
</SheetContent>
</Sheet>
);
}