Files
optima/src/components/CreateOpportunityModal.svelte
T

2388 lines
66 KiB
Svelte

<script lang="ts">
import { goto } from "$app/navigation";
import type { CWMember } from "$lib/optima-api/modules/cw";
interface CompanyContact {
firstName?: string;
lastName?: string;
cwId?: number;
inactive?: boolean;
title?: string;
phone?: string;
email?: string;
}
interface CompanyAddress {
line1?: string;
line2?: string | null;
city?: string;
state?: string;
zip?: string;
country?: string;
}
export let isOpen = false;
export let onSuccess: () => void = () => {};
// ── Form state ──
let name = "";
let expectedCloseDate = "";
let description = "";
let source = "";
let customerPO = "";
let primarySalesRepId = "";
let secondarySalesRepId = "";
let companyId = "";
let companyName = "";
let companyOptimaId = "";
// ── Contact & Site state ──
let contacts: CompanyContact[] = [];
let selectedContactId = "";
let companyAddress: CompanyAddress | null = null;
let isLoadingCompanyDetails = false;
// ── UI state ──
let isSubmitting = false;
let submitError = "";
let currentStep = 0; // 0 = details, 1 = assignment, 2 = contact & site, 3 = review
let members: CWMember[] = [];
let isLoadingMembers = false;
let companies: { id: number; optimaId: string; name: string }[] = [];
let isSearchingCompanies = false;
let companySearchInput = "";
let companySearchTimer: ReturnType<typeof setTimeout>;
let showCompanyDropdown = false;
let navigateOnCreate = true;
let companyInputWrapEl: HTMLDivElement;
let dropdownStyle = "";
const steps = [
{ label: "Details", icon: "document" },
{ label: "Assignment", icon: "people" },
{ label: "Contact & Site", icon: "contact" },
{ label: "Review", icon: "check" },
];
// ── Validation ──
$: isStep0Valid = name.trim().length > 0 && expectedCloseDate.length > 0;
$: isStep1Valid = primarySalesRepId.length > 0 && companyId.length > 0;
$: isStep2Valid = true; // Contact & Site step is optional
$: isValid = isStep0Valid && isStep1Valid && isStep2Valid;
$: selectedPrimaryRep = members.find(
(m) => String(m.id) === primarySalesRepId,
);
$: selectedSecondaryRep = members.find(
(m) => String(m.id) === secondarySalesRepId,
);
$: selectedContact = contacts.find(
(c) => String(c.cwId) === selectedContactId,
);
$: activeContacts = contacts.filter((c) => !c.inactive);
// ── Default close date to 30 days from now ──
$: if (isOpen && !expectedCloseDate) {
const d = new Date();
d.setDate(d.getDate() + 30);
expectedCloseDate = d.toISOString().split("T")[0];
}
// ── Load CW members for sales rep dropdowns ──
async function loadMembers() {
if (members.length > 0) return;
isLoadingMembers = true;
try {
const res = await fetch("/api/cw/members");
const json = await res.json();
members = json.data ?? [];
// Auto-select first member as primary sales rep if not already set
if (!primarySalesRepId && members.length > 0) {
primarySalesRepId = String(members[0].id);
}
} catch (err) {
console.error("Failed to load members:", err);
} finally {
isLoadingMembers = false;
}
}
// ── Company search ──
function updateDropdownPosition() {
if (!companyInputWrapEl) return;
const rect = companyInputWrapEl.getBoundingClientRect();
dropdownStyle = `position:fixed; top:${rect.bottom + 4}px; left:${rect.left}px; width:${rect.width}px;`;
}
function handleCompanySearch() {
clearTimeout(companySearchTimer);
showCompanyDropdown = true;
if (!companySearchInput.trim()) {
companies = [];
return;
}
isSearchingCompanies = true;
companySearchTimer = setTimeout(async () => {
try {
const res = await fetch(
`/api/companies/search?search=${encodeURIComponent(companySearchInput)}&rpp=10`,
);
const json = await res.json();
companies = (json.data ?? []).map(
(c: { id: string; name: string; cw_CompanyId?: number }) => ({
id: c.cw_CompanyId ?? Number(c.id),
optimaId: String(c.id),
name: c.name,
}),
);
requestAnimationFrame(updateDropdownPosition);
} catch {
companies = [];
} finally {
isSearchingCompanies = false;
}
}, 300);
}
function selectCompany(c: { id: number; optimaId: string; name: string }) {
companyId = String(c.id);
companyOptimaId = c.optimaId;
companyName = c.name;
companySearchInput = c.name;
showCompanyDropdown = false;
companies = [];
// Reset contact/site when company changes
contacts = [];
selectedContactId = "";
companyAddress = null;
}
function clearCompany() {
companyId = "";
companyOptimaId = "";
companyName = "";
companySearchInput = "";
companies = [];
showCompanyDropdown = false;
// Clear contact/site data
contacts = [];
selectedContactId = "";
companyAddress = null;
}
// ── Load company details (contacts + address) ──
async function loadCompanyDetails() {
if (!companyOptimaId) return;
isLoadingCompanyDetails = true;
try {
const res = await fetch(`/api/companies/${companyOptimaId}/details`);
const json = await res.json();
const data = json.data;
if (data) {
const allContacts: CompanyContact[] = data.cw_Data?.allContacts ?? [];
contacts = allContacts;
companyAddress = data.cw_Data?.address ?? null;
// Auto-select first active contact as default
const active = allContacts.filter((c: CompanyContact) => !c.inactive);
if (active.length > 0 && active[0].cwId) {
selectedContactId = String(active[0].cwId);
} else if (allContacts.length > 0 && allContacts[0].cwId) {
selectedContactId = String(allContacts[0].cwId);
}
}
} catch (err) {
console.error("Failed to load company details:", err);
} finally {
isLoadingCompanyDetails = false;
}
}
// ── Step navigation ──
function nextStep() {
if (currentStep === 0 && !isStep0Valid) return;
if (currentStep === 1 && !isStep1Valid) return;
if (currentStep < 3) {
currentStep++;
if (currentStep === 1) loadMembers();
if (currentStep === 2) loadCompanyDetails();
}
}
function prevStep() {
if (currentStep > 0) currentStep--;
}
function goToStep(step: number) {
if (step === 0) {
currentStep = 0;
return;
}
if (step === 1 && isStep0Valid) {
currentStep = 1;
loadMembers();
return;
}
if (step === 2 && isStep0Valid && isStep1Valid) {
currentStep = 2;
loadCompanyDetails();
return;
}
if (step === 3 && isStep0Valid && isStep1Valid && isStep2Valid) {
currentStep = 3;
return;
}
}
// ── Submit ──
async function handleSubmit() {
if (!isValid) return;
isSubmitting = true;
submitError = "";
try {
const body: Record<string, unknown> = {
name: name.trim(),
expectedCloseDate,
};
if (description.trim()) body.notes = description.trim();
if (source.trim()) body.source = source.trim();
if (customerPO.trim()) body.customerPO = customerPO.trim();
if (primarySalesRepId)
body.primarySalesRep = { id: Number(primarySalesRepId) };
if (secondarySalesRepId)
body.secondarySalesRep = { id: Number(secondarySalesRepId) };
if (companyId) body.company = { id: Number(companyId) };
if (selectedContactId) body.contact = { id: Number(selectedContactId) };
const res = await fetch("/api/sales/opportunities", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(
errData.message || `Failed to create opportunity (${res.status})`,
);
}
const result = await res.json();
const newId = result?.data?.id;
const shouldNavigate = navigateOnCreate && !!newId;
if (!newId) {
console.warn("No opportunity ID in response:", JSON.stringify(result));
}
reset();
if (shouldNavigate) {
await goto(`/sales/opportunity/${newId}`);
} else {
onSuccess();
}
} catch (err) {
submitError =
err instanceof Error ? err.message : "Failed to create opportunity";
console.error("Failed to create opportunity:", err);
} finally {
isSubmitting = false;
}
}
// ── Reset ──
function reset() {
name = "";
expectedCloseDate = "";
description = "";
source = "";
customerPO = "";
primarySalesRepId = "";
secondarySalesRepId = "";
companyId = "";
companyOptimaId = "";
companyName = "";
companySearchInput = "";
companies = [];
showCompanyDropdown = false;
contacts = [];
selectedContactId = "";
companyAddress = null;
isLoadingCompanyDetails = false;
isSubmitting = false;
submitError = "";
currentStep = 0;
navigateOnCreate = true;
}
function handleClose() {
reset();
isOpen = false;
}
function handleBackdropClick() {
handleClose();
}
function handleBackdropKeydown(e: KeyboardEvent) {
if (e.key === "Escape") handleClose();
}
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="co-modal-backdrop"
on:click={handleBackdropClick}
on:keydown={handleBackdropKeydown}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="co-modal"
role="dialog"
aria-modal="true"
aria-label="Create Opportunity"
tabindex="-1"
on:click|stopPropagation
>
<!-- ── Header ── -->
<div class="co-modal-header">
<div class="co-modal-title-group">
<div class="co-modal-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="22"
height="22"
>
<path
d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z"
/>
</svg>
</div>
<div>
<h2>Create Opportunity</h2>
<p class="co-modal-subtitle">Set up a new sales opportunity</p>
</div>
</div>
<button
class="co-close-btn"
on:click={handleClose}
type="button"
aria-label="Close modal"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="18"
height="18"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- ── Step indicator ── -->
<div class="co-steps">
{#each steps as step, i}
<button
class="co-step"
class:active={i === currentStep}
class:completed={i < currentStep}
class:disabled={(i > 0 && !isStep0Valid) ||
(i > 1 && !isStep1Valid) ||
(i > 2 && !isStep2Valid)}
on:click={() => goToStep(i)}
disabled={(i > 0 && !isStep0Valid) ||
(i > 1 && !isStep1Valid) ||
(i > 2 && !isStep2Valid)}
>
<span class="co-step-number" class:check={i < currentStep}>
{#if i < currentStep}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
width="12"
height="12"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{:else}
{i + 1}
{/if}
</span>
<span class="co-step-label">{step.label}</span>
</button>
{#if i < steps.length - 1}
<div class="co-step-connector" class:filled={i < currentStep}></div>
{/if}
{/each}
</div>
<!-- ── Body ── -->
<div class="co-modal-body">
{#if submitError}
<div class="co-error-banner">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="16"
height="16"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span>{submitError}</span>
<button
class="co-error-dismiss"
on:click={() => (submitError = "")}
aria-label="Dismiss error"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/if}
<!-- Step 0: Details -->
{#if currentStep === 0}
<div class="co-step-content" style="animation: coFadeIn 0.2s ease">
<div class="co-section">
<h3 class="co-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
</svg>
Opportunity Details
</h3>
<div class="co-form-grid">
<div class="co-form-group co-full-width">
<label for="co-name">
Opportunity Name <span class="co-req">*</span>
</label>
<input
id="co-name"
type="text"
bind:value={name}
placeholder="e.g. Network Upgrade Acme Corp"
disabled={isSubmitting}
class:has-value={name.trim().length > 0}
/>
</div>
<div class="co-form-group">
<label for="co-close-date">
Expected Close Date <span class="co-req">*</span>
</label>
<input
id="co-close-date"
type="date"
bind:value={expectedCloseDate}
disabled={isSubmitting}
class:has-value={expectedCloseDate.length > 0}
/>
</div>
<div class="co-form-group">
<label for="co-source">Source</label>
<input
id="co-source"
type="text"
bind:value={source}
placeholder="e.g. Referral, Website, Cold Call"
disabled={isSubmitting}
/>
</div>
<div class="co-form-group">
<label for="co-po">Customer PO</label>
<input
id="co-po"
type="text"
bind:value={customerPO}
placeholder="PO number (optional)"
disabled={isSubmitting}
/>
</div>
</div>
</div>
<div class="co-section">
<h3 class="co-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
Description
</h3>
<textarea
class="co-notes-textarea"
bind:value={description}
placeholder="Add a description for this opportunity…"
rows="3"
disabled={isSubmitting}
></textarea>
</div>
</div>
<!-- Step 1: Assignment -->
{:else if currentStep === 1}
<div class="co-step-content" style="animation: coFadeIn 0.2s ease">
<div class="co-section">
<h3 class="co-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87" />
<path d="M16 3.13a4 4 0 010 7.75" />
</svg>
Sales Team
</h3>
<div class="co-form-grid">
<div class="co-form-group">
<label for="co-primary-rep"
>Primary Sales Rep <span class="co-req">*</span></label
>
{#if isLoadingMembers}
<div class="co-loading-field">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
class="co-spin-icon"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
fill="none"
opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
/>
</svg>
Loading team members…
</div>
{:else}
<select
id="co-primary-rep"
bind:value={primarySalesRepId}
disabled={isSubmitting}
>
<option value="">— Select rep —</option>
{#each members as m}
<option value={String(m.id)}>{m.name}</option>
{/each}
</select>
{/if}
</div>
<div class="co-form-group">
<label for="co-secondary-rep">Secondary Sales Rep</label>
{#if isLoadingMembers}
<div class="co-loading-field">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
class="co-spin-icon"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
fill="none"
opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
/>
</svg>
Loading team members…
</div>
{:else}
<select
id="co-secondary-rep"
bind:value={secondarySalesRepId}
disabled={isSubmitting}
>
<option value="">— None —</option>
{#each members.filter((m) => String(m.id) !== primarySalesRepId) as m}
<option value={String(m.id)}>{m.name}</option>
{/each}
</select>
{/if}
</div>
</div>
</div>
<div class="co-section">
<h3 class="co-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
Company
</h3>
<div class="co-form-grid">
<div class="co-form-group co-full-width co-company-search-wrap">
<label for="co-company-search"
>Company <span class="co-req">*</span></label
>
{#if companyId}
<div class="co-selected-company">
<span class="co-company-chip">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"
/>
</svg>
{companyName}
</span>
<button
class="co-company-clear"
on:click={clearCompany}
type="button"
aria-label="Clear company"
disabled={isSubmitting}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{:else}
<div
class="co-search-input-wrap"
bind:this={companyInputWrapEl}
>
<svg
class="co-search-input-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<input
id="co-company-search"
type="text"
bind:value={companySearchInput}
on:input={handleCompanySearch}
on:focus={() => {
if (companySearchInput.trim()) {
showCompanyDropdown = true;
requestAnimationFrame(updateDropdownPosition);
}
}}
placeholder="Search for a company…"
disabled={isSubmitting}
autocomplete="off"
/>
{#if isSearchingCompanies}
<svg
viewBox="0 0 24 24"
width="14"
height="14"
class="co-spin-icon co-search-spinner"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
fill="none"
opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
/>
</svg>
{/if}
</div>
{#if showCompanyDropdown && companies.length > 0}
<ul class="co-company-dropdown" style={dropdownStyle}>
{#each companies as c}
<li>
<button
type="button"
on:click={() => selectCompany(c)}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"
/>
</svg>
{c.name}
</button>
</li>
{/each}
</ul>
{:else if showCompanyDropdown && companySearchInput.trim() && !isSearchingCompanies}
<ul class="co-company-dropdown" style={dropdownStyle}>
<li class="co-company-empty">No companies found</li>
</ul>
{/if}
{/if}
</div>
</div>
</div>
</div>
<!-- Step 2: Contact & Site -->
{:else if currentStep === 2}
<div class="co-step-content" style="animation: coFadeIn 0.2s ease">
<div class="co-section">
<h3 class="co-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
Company Contact
</h3>
{#if isLoadingCompanyDetails}
<div class="co-loading-field">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
class="co-spin-icon"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
fill="none"
opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
/>
</svg>
Loading company details…
</div>
{:else}
<div class="co-form-grid">
<div class="co-form-group co-full-width">
<label for="co-contact">Contact</label>
<select
id="co-contact"
bind:value={selectedContactId}
disabled={isSubmitting}
>
<option value="">— None —</option>
{#each activeContacts as c}
<option value={String(c.cwId)}>
{c.firstName}
{c.lastName}{c.title ? ` — ${c.title}` : ""}
</option>
{/each}
{#if contacts.some((c) => c.inactive)}
<optgroup label="Inactive">
{#each contacts.filter((c) => c.inactive) as c}
<option value={String(c.cwId)}>
{c.firstName}
{c.lastName}{c.title ? ` ${c.title}` : ""} (inactive)
</option>
{/each}
</optgroup>
{/if}
</select>
</div>
</div>
{#if selectedContact}
<div class="co-contact-details">
<div class="co-contact-details-header">
<div class="co-contact-avatar">
{(selectedContact.firstName?.[0] ?? "").toUpperCase()}{(
selectedContact.lastName?.[0] ?? ""
).toUpperCase()}
</div>
<div>
<p class="co-contact-name">
{selectedContact.firstName}
{selectedContact.lastName}
</p>
{#if selectedContact.title}
<p class="co-contact-title">
{selectedContact.title}
</p>
{/if}
</div>
</div>
<div class="co-contact-info-grid">
{#if selectedContact.email}
<div class="co-contact-info-item">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"
/>
<polyline points="22,6 12,13 2,6" />
</svg>
<span>{selectedContact.email}</span>
</div>
{/if}
{#if selectedContact.phone}
<div class="co-contact-info-item">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="13"
height="13"
>
<path
d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72 12.84 12.84 0 00.7 2.81 2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45 12.84 12.84 0 002.81.7A2 2 0 0122 16.92z"
/>
</svg>
<span>{selectedContact.phone}</span>
</div>
{/if}
</div>
</div>
{/if}
{#if contacts.length === 0 && !isLoadingCompanyDetails}
<div class="co-empty-state">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="24"
height="24"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<p>No contacts found for this company</p>
</div>
{/if}
{/if}
</div>
<div class="co-section">
<h3 class="co-section-title">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="15"
height="15"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
Company Site
</h3>
{#if isLoadingCompanyDetails}
<div class="co-loading-field">
<svg
viewBox="0 0 24 24"
width="14"
height="14"
class="co-spin-icon"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
fill="none"
opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
/>
</svg>
Loading site information…
</div>
{:else if companyAddress}
<div class="co-site-card">
<div class="co-site-card-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="20"
height="20"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<div class="co-site-card-details">
<p class="co-site-label">Primary Address</p>
<p class="co-site-line">{companyAddress.line1 ?? ""}</p>
{#if companyAddress.line2}
<p class="co-site-line">{companyAddress.line2}</p>
{/if}
<p class="co-site-line">
{companyAddress.city ?? ""}{companyAddress.city &&
companyAddress.state
? ", "
: ""}{companyAddress.state ?? ""}
{companyAddress.zip ?? ""}
</p>
{#if companyAddress.country && companyAddress.country !== "United States"}
<p class="co-site-line">{companyAddress.country}</p>
{/if}
</div>
</div>
{:else}
<div class="co-empty-state">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="24"
height="24"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<p>No address on file for this company</p>
</div>
{/if}
</div>
</div>
<!-- Step 3: Review -->
{:else if currentStep === 3}
<div class="co-step-content" style="animation: coFadeIn 0.2s ease">
<div class="co-review">
<div class="co-review-header">
<div class="co-review-icon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
width="28"
height="28"
>
<path
d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z"
/>
</svg>
</div>
<h3 class="co-review-title">{name}</h3>
</div>
<div class="co-review-grid">
<div class="co-review-card">
<h4>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
Details
</h4>
<dl>
<div class="co-review-item">
<dt>Expected Close</dt>
<dd>
{new Date(
expectedCloseDate + "T00:00:00",
).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
</dd>
</div>
{#if source}
<div class="co-review-item">
<dt>Source</dt>
<dd>{source}</dd>
</div>
{/if}
{#if customerPO}
<div class="co-review-item">
<dt>Customer PO</dt>
<dd class="mono">{customerPO}</dd>
</div>
{/if}
</dl>
</div>
<div class="co-review-card">
<h4>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
</svg>
Assignment
</h4>
<dl>
<div class="co-review-item">
<dt>Primary Rep</dt>
<dd>{selectedPrimaryRep?.name || "—"}</dd>
</div>
{#if selectedSecondaryRep}
<div class="co-review-item">
<dt>Secondary Rep</dt>
<dd>{selectedSecondaryRep.name}</dd>
</div>
{/if}
<div class="co-review-item">
<dt>Company</dt>
<dd>{companyName || "—"}</dd>
</div>
</dl>
</div>
<div class="co-review-card">
<h4>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
Contact & Site
</h4>
<dl>
<div class="co-review-item">
<dt>Contact</dt>
<dd>
{selectedContact
? `${selectedContact.firstName} ${selectedContact.lastName}`
: "—"}
</dd>
</div>
{#if companyAddress}
<div class="co-review-item">
<dt>Site</dt>
<dd>
{companyAddress.city ?? ""}{companyAddress.city &&
companyAddress.state
? ", "
: ""}{companyAddress.state ?? ""}
</dd>
</div>
{/if}
</dl>
</div>
</div>
{#if description}
<div class="co-review-notes">
<h4>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"
/>
<path
d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"
/>
</svg>
Description
</h4>
<p>{description}</p>
</div>
{/if}
<label class="co-navigate-check">
<input type="checkbox" bind:checked={navigateOnCreate} />
<span>Open opportunity after creating</span>
</label>
</div>
</div>
{/if}
</div>
<!-- ── Footer ── -->
<div class="co-modal-footer">
{#if currentStep > 0}
<button
class="co-btn-back"
on:click={prevStep}
disabled={isSubmitting}
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Back
</button>
{/if}
<div class="co-footer-spacer"></div>
<button
class="co-btn-cancel"
on:click={handleClose}
disabled={isSubmitting}
type="button"
>
Cancel
</button>
{#if currentStep < 3}
<button
class="co-btn-next"
on:click={nextStep}
disabled={(currentStep === 0 && !isStep0Valid) ||
(currentStep === 1 && !isStep1Valid) ||
(currentStep === 2 && !isStep2Valid)}
type="button"
>
{currentStep === 0
? "Next: Assignment"
: currentStep === 1
? "Next: Contact & Site"
: "Review"}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</button>
{:else}
<button
class="co-btn-submit"
on:click={handleSubmit}
disabled={!isValid || isSubmitting}
type="button"
>
{#if isSubmitting}
<svg
viewBox="0 0 24 24"
width="14"
height="14"
class="co-spin-icon"
>
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="3"
fill="none"
opacity="0.25"
/>
<path
d="M12 2a10 10 0 0 1 10 10"
stroke="currentColor"
stroke-width="3"
fill="none"
stroke-linecap="round"
/>
</svg>
Creating…
{:else}
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<path
d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z"
/>
</svg>
Create Opportunity
{/if}
</button>
{/if}
</div>
</div>
</div>
{/if}
<style>
/* ── Backdrop ── */
.co-modal-backdrop {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
animation: coBackdropIn 0.2s ease;
}
/* ── Modal shell ── */
.co-modal {
display: flex;
flex-direction: column;
width: 640px;
max-width: 94vw;
max-height: 88vh;
background: var(--bg-surface, #ffffff);
border: 1px solid var(--border-subtle, #e5e5e5);
border-radius: 16px;
box-shadow:
0 24px 80px -12px rgba(0, 0, 0, 0.28),
0 0 0 1px rgba(0, 0, 0, 0.03);
animation: coModalIn 0.25s cubic-bezier(0.32, 0.72, 0, 1);
overflow: hidden;
}
/* ── Header ── */
.co-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border-subtle, #e5e5e5);
background: linear-gradient(
135deg,
rgba(59, 130, 246, 0.04) 0%,
rgba(168, 85, 247, 0.04) 100%
);
}
.co-modal-title-group {
display: flex;
align-items: center;
gap: 14px;
}
.co-modal-icon {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border-radius: 12px;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
color: #ffffff;
flex-shrink: 0;
}
.co-modal-title-group h2 {
margin: 0;
font-size: 17px;
font-weight: 650;
color: var(--text-primary, #1a1a1a);
letter-spacing: -0.01em;
}
.co-modal-subtitle {
margin: 2px 0 0;
font-size: 12.5px;
color: var(--text-muted, #888);
font-weight: 400;
}
.co-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: none;
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
color: var(--text-muted, #888);
transition: all 0.15s;
}
.co-close-btn:hover {
background: var(--card-hover-bg, #f5f5f5);
color: var(--text-primary, #1a1a1a);
border-color: var(--border-subtle, #e5e5e5);
}
/* ── Step indicator ── */
.co-steps {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
padding: 16px 24px;
border-bottom: 1px solid var(--border-subtle, #e5e5e5);
background: var(--bg-inset, #fafafa);
}
.co-step {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border: none;
background: none;
cursor: pointer;
border-radius: 8px;
transition: all 0.15s;
}
.co-step:hover:not(.disabled) {
background: var(--card-hover-bg, #f0f0f0);
}
.co-step.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.co-step-number {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
border: 2px solid var(--border-subtle, #ddd);
color: var(--text-muted, #888);
background: var(--bg-surface, #fff);
transition: all 0.2s;
}
.co-step.active .co-step-number {
border-color: #3b82f6;
background: #3b82f6;
color: #fff;
}
.co-step.completed .co-step-number,
.co-step-number.check {
border-color: #22c55e;
background: #22c55e;
color: #fff;
}
.co-step-label {
font-size: 12.5px;
font-weight: 550;
color: var(--text-secondary, #666);
transition: color 0.15s;
}
.co-step.active .co-step-label {
color: var(--text-primary, #1a1a1a);
}
.co-step-connector {
width: 32px;
height: 2px;
background: var(--border-subtle, #ddd);
border-radius: 1px;
transition: background 0.2s;
}
.co-step-connector.filled {
background: #22c55e;
}
/* ── Body ── */
.co-modal-body {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
.co-step-content {
display: flex;
flex-direction: column;
gap: 20px;
}
/* ── Sections ── */
.co-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.co-section-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 13px;
font-weight: 650;
color: var(--text-primary, #1a1a1a);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.co-section-title svg {
color: var(--text-muted, #888);
}
/* ── Form grid ── */
.co-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.co-full-width {
grid-column: 1 / -1;
}
.co-form-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.co-form-group label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary, #666);
letter-spacing: 0.01em;
}
.co-req {
color: #ef4444;
font-weight: 700;
}
.co-form-group input[type="text"],
.co-form-group input[type="date"],
.co-form-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-subtle, #ddd);
border-radius: 8px;
background: var(--bg-inset, #fafafa);
font-size: 13px;
color: var(--text-primary, #1a1a1a);
box-sizing: border-box;
transition:
border-color 0.15s,
box-shadow 0.15s,
background 0.15s;
font-family: inherit;
}
.co-form-group input:focus,
.co-form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
background: var(--bg-surface, #fff);
}
.co-form-group input::placeholder {
color: var(--text-muted, #aaa);
}
.co-form-group input:disabled,
.co-form-group select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.co-form-group select {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.5'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
padding-right: 30px;
}
/* ── Notes textarea ── */
.co-notes-textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-subtle, #ddd);
border-radius: 8px;
background: var(--bg-inset, #fafafa);
font-size: 13px;
color: var(--text-primary, #1a1a1a);
box-sizing: border-box;
resize: vertical;
font-family: inherit;
line-height: 1.5;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.co-notes-textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
background: var(--bg-surface, #fff);
}
.co-notes-textarea::placeholder {
color: var(--text-muted, #aaa);
}
.co-notes-textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── Loading field ── */
.co-loading-field {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid var(--border-subtle, #ddd);
border-radius: 8px;
background: var(--bg-inset, #fafafa);
font-size: 12.5px;
color: var(--text-muted, #888);
}
.co-spin-icon {
animation: coSpin 0.8s linear infinite;
color: var(--text-muted, #888);
flex-shrink: 0;
}
/* ── Company search ── */
.co-company-search-wrap {
position: relative;
}
.co-search-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.co-search-input-icon {
position: absolute;
left: 10px;
color: var(--text-muted, #aaa);
pointer-events: none;
}
.co-search-input-wrap input {
padding-left: 32px !important;
}
.co-search-spinner {
position: absolute;
right: 10px;
}
.co-company-dropdown {
position: fixed;
list-style: none;
padding: 4px;
background: var(--bg-surface, #fff);
border: 1px solid var(--border-subtle, #ddd);
border-radius: 10px;
box-shadow:
0 8px 24px -4px rgba(0, 0, 0, 0.12),
0 0 0 1px rgba(0, 0, 0, 0.03);
z-index: 10001;
max-height: 180px;
overflow-y: auto;
}
.co-company-dropdown li button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
background: none;
border: none;
border-radius: 7px;
font-size: 13px;
color: var(--text-primary, #1a1a1a);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.co-company-dropdown li button:hover {
background: var(--card-hover-bg, #f5f5f5);
}
.co-company-dropdown li button svg {
color: var(--text-muted, #aaa);
flex-shrink: 0;
}
.co-company-empty {
padding: 12px 10px;
font-size: 12.5px;
color: var(--text-muted, #888);
text-align: center;
}
.co-selected-company {
display: flex;
align-items: center;
gap: 8px;
}
.co-company-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 8px;
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.2);
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #1a1a1a);
}
.co-company-chip svg {
color: #3b82f6;
}
.co-company-clear {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: none;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
color: var(--text-muted, #888);
transition: all 0.1s;
}
.co-company-clear:hover:not(:disabled) {
background: rgba(220, 38, 38, 0.06);
color: #dc2626;
}
.co-company-clear:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Contact details ── */
.co-contact-details {
border: 1px solid var(--border-subtle, #e5e5e5);
border-radius: 12px;
padding: 16px;
background: var(--bg-inset, #fafafa);
display: flex;
flex-direction: column;
gap: 14px;
}
.co-contact-details-header {
display: flex;
align-items: center;
gap: 12px;
}
.co-contact-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
color: #fff;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
flex-shrink: 0;
}
.co-contact-name {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
}
.co-contact-title {
margin: 2px 0 0;
font-size: 12px;
color: var(--text-muted, #888);
}
.co-contact-info-grid {
display: flex;
flex-direction: column;
gap: 8px;
padding-left: 4px;
}
.co-contact-info-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
color: var(--text-secondary, #666);
}
.co-contact-info-item svg {
color: var(--text-muted, #aaa);
flex-shrink: 0;
}
/* ── Site card ── */
.co-site-card {
display: flex;
align-items: flex-start;
gap: 14px;
border: 1px solid var(--border-subtle, #e5e5e5);
border-radius: 12px;
padding: 16px;
background: var(--bg-inset, #fafafa);
}
.co-site-card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
background: rgba(34, 197, 94, 0.08);
color: #22c55e;
flex-shrink: 0;
}
.co-site-card-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.co-site-label {
margin: 0 0 4px;
font-size: 11px;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted, #888);
}
.co-site-line {
margin: 0;
font-size: 13px;
color: var(--text-primary, #1a1a1a);
line-height: 1.45;
}
/* ── Empty state ── */
.co-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 16px;
border: 1px dashed var(--border-subtle, #ddd);
border-radius: 12px;
background: var(--bg-inset, #fafafa);
}
.co-empty-state svg {
color: var(--text-muted, #bbb);
}
.co-empty-state p {
margin: 0;
font-size: 13px;
color: var(--text-muted, #888);
}
/* ── Review ── */
.co-review {
display: flex;
flex-direction: column;
gap: 18px;
}
.co-review-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text-align: center;
padding: 8px 0 4px;
}
.co-review-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 16px;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
color: #fff;
box-shadow: 0 4px 16px -2px rgba(59, 130, 246, 0.3);
}
.co-review-title {
margin: 0;
font-size: 18px;
font-weight: 650;
color: var(--text-primary, #1a1a1a);
letter-spacing: -0.01em;
word-break: break-word;
}
.co-review-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.co-review-card {
border: 1px solid var(--border-subtle, #e5e5e5);
border-radius: 12px;
padding: 14px 16px;
background: var(--bg-inset, #fafafa);
}
.co-review-card h4,
.co-review-notes h4 {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 10px;
font-size: 11.5px;
font-weight: 650;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted, #888);
}
.co-review-card dl {
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.co-review-item {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
}
.co-review-item dt {
font-size: 12px;
color: var(--text-muted, #888);
flex-shrink: 0;
}
.co-review-item dd {
margin: 0;
font-size: 13px;
font-weight: 500;
color: var(--text-primary, #1a1a1a);
text-align: right;
word-break: break-word;
}
.co-review-item dd.mono {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 12px;
}
.co-review-notes {
border: 1px solid var(--border-subtle, #e5e5e5);
border-radius: 12px;
padding: 14px 16px;
background: var(--bg-inset, #fafafa);
}
.co-review-notes p {
margin: 0;
font-size: 13px;
color: var(--text-primary, #1a1a1a);
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.co-navigate-check {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 8px;
background: var(--bg-inset, #fafafa);
border: 1px solid var(--border-subtle, #e5e5e5);
cursor: pointer;
transition: background 0.1s;
}
.co-navigate-check:hover {
background: var(--card-hover-bg, #f0f0f0);
}
.co-navigate-check input {
accent-color: #3b82f6;
}
.co-navigate-check span {
font-size: 12.5px;
font-weight: 500;
color: var(--text-secondary, #666);
}
/* ── Error banner ── */
.co-error-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 10px;
background: rgba(220, 38, 38, 0.06);
border: 1px solid rgba(220, 38, 38, 0.15);
margin-bottom: 4px;
}
.co-error-banner svg {
color: #dc2626;
flex-shrink: 0;
}
.co-error-banner span {
flex: 1;
font-size: 13px;
color: #dc2626;
font-weight: 500;
}
.co-error-dismiss {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: none;
border: none;
border-radius: 5px;
cursor: pointer;
color: #dc2626;
opacity: 0.6;
transition: opacity 0.1s;
}
.co-error-dismiss:hover {
opacity: 1;
}
/* ── Footer ── */
.co-modal-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 24px;
border-top: 1px solid var(--border-subtle, #e5e5e5);
flex-shrink: 0;
}
.co-footer-spacer {
flex: 1;
}
.co-btn-back {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 7px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
background: none;
border: 1px solid var(--border-subtle, #ddd);
color: var(--text-secondary, #666);
transition: all 0.15s;
}
.co-btn-back:hover:not(:disabled) {
background: var(--card-hover-bg, #f5f5f5);
border-color: var(--border-default, #ccc);
color: var(--text-primary, #1a1a1a);
}
.co-btn-cancel {
padding: 7px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
background: var(--bg-inset, #fafafa);
border: 1px solid var(--border-subtle, #ddd);
color: var(--text-secondary, #666);
transition: all 0.15s;
}
.co-btn-cancel:hover:not(:disabled) {
background: var(--card-hover-bg, #f0f0f0);
border-color: var(--border-default, #ccc);
color: var(--text-primary, #1a1a1a);
}
.co-btn-cancel:disabled,
.co-btn-back:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.co-btn-next {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 550;
cursor: pointer;
background: var(--bg-surface, #fff);
border: 1px solid #3b82f6;
color: #3b82f6;
transition: all 0.15s;
}
.co-btn-next:hover:not(:disabled) {
background: rgba(59, 130, 246, 0.06);
}
.co-btn-next:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.co-btn-submit {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
background: linear-gradient(135deg, #3b82f6 0%, #6366f1 100%);
border: 1px solid transparent;
color: #fff;
transition: all 0.15s;
box-shadow: 0 2px 8px -2px rgba(59, 130, 246, 0.35);
}
.co-btn-submit:hover:not(:disabled) {
filter: brightness(1.08);
box-shadow: 0 4px 12px -2px rgba(59, 130, 246, 0.4);
transform: translateY(-0.5px);
}
.co-btn-submit:active:not(:disabled) {
transform: translateY(0);
}
.co-btn-submit:disabled {
opacity: 0.45;
cursor: not-allowed;
box-shadow: none;
}
/* ── Animations ── */
@keyframes coBackdropIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes coModalIn {
from {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes coFadeIn {
from {
opacity: 0;
transform: translateX(6px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes coSpin {
to {
transform: rotate(360deg);
}
}
/* ── Mobile ── */
@media (max-width: 768px) {
.co-modal-backdrop {
align-items: flex-end;
}
.co-modal {
width: 100%;
max-width: 100%;
max-height: 94vh;
border-radius: 16px 16px 0 0;
animation: coSheetIn 0.3s cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes coSheetIn {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.co-modal-header {
padding: 16px 18px 14px;
}
.co-modal-body {
padding: 16px 18px;
}
.co-modal-footer {
padding: 12px 18px;
}
.co-steps {
padding: 12px 16px;
gap: 0;
}
.co-step {
padding: 5px 10px;
}
.co-step-label {
font-size: 11.5px;
}
.co-step-connector {
width: 20px;
}
.co-form-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.co-review-grid {
grid-template-columns: 1fr;
}
.co-form-group input[type="text"],
.co-form-group input[type="date"],
.co-form-group select {
font-size: 16px;
padding: 10px 12px;
}
.co-notes-textarea {
font-size: 16px;
}
.co-btn-cancel,
.co-btn-next,
.co-btn-submit,
.co-btn-back {
padding: 10px 18px;
font-size: 14px;
}
}
</style>