"use client"; import { zodResolver } from "@hookform/resolvers/zod"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { Loader2 } from "lucide-react"; import { z } from "zod"; import { FormField } from "@/components/ui/form-field"; import { Select } from "@/components/ui/select"; import { api } from "@/lib/api-client"; import { useCompanies, useCreateCompany } from "@/hooks/use-companies"; import { useCreateNotificationDestination } from "@/hooks/use-notification-destinations"; import { useDiscoverCompany } from "@/hooks/use-discovery"; import { authErrorMessage, useSystemStatus } from "@/hooks/use-auth"; import { FREQUENCY_LABELS, MONITORING_FREQUENCIES, SEVERITY_LABELS, SEVERITY_LEVELS, type CompanyResponse, type DiscoveredCompanyProfile, type MonitoringFrequency, type SeverityLevel, } from "@/lib/types"; const _LEGAL_SUFFIX_RE = /\b(inc|incorporated|llc|ltd|limited|corp|corporation|co|company|plc)\b/g; function normalizeCompanyName(name: string): string { return name .toLowerCase() .replace(/[.,]/g, "") .replace(_LEGAL_SUFFIX_RE, "") .replace(/\s+/g, " ") .trim(); } function findPossibleDuplicate( name: string, companies: CompanyResponse[], ): CompanyResponse | undefined { const normalized = normalizeCompanyName(name); if (!normalized) return undefined; return companies.find((c) => { const existing = normalizeCompanyName(c.name); return ( existing.length > 0 && (existing === normalized || existing.includes(normalized) || normalized.includes(existing)) ); }); } const companySchema = z .object({ name: z.string().min(1, "Company name is required").max(200), officialWebsite: z.string().max(500).optional().or(z.literal("")), industry: z.string().max(120).optional().or(z.literal("")), country: z.string().max(120).optional().or(z.literal("")), region: z.string().max(120).optional().or(z.literal("")), headquarters: z.string().max(200).optional().or(z.literal("")), description: z.string().max(4000).optional().or(z.literal("")), monitoringFocus: z.string().max(2000).optional().or(z.literal("")), competitorNames: z.string().optional().or(z.literal("")), aliasNames: z.string().optional().or(z.literal("")), frequencyType: z.enum(MONITORING_FREQUENCIES), intervalMinutes: z.coerce.number().int().positive().optional(), cronExpression: z.string().max(120).optional().or(z.literal("")), timezone: z.string().min(1).max(64), severityThreshold: z.enum(SEVERITY_LEVELS), notificationEmail: z.string().email("Enter a valid email address"), consent: z.literal(true, { errorMap: () => ({ message: "You must acknowledge the collection policy to continue" }), }), }) .refine( (data) => data.frequencyType !== "custom" || Boolean(data.intervalMinutes) || Boolean(data.cronExpression), { message: "Provide either an interval in minutes or a cron expression for a custom schedule", path: ["intervalMinutes"], }, ); type CompanyForm = z.infer; const STEPS = ["Discover", "Review", "Schedule", "Notifications", "Confirm"] as const; const STEP_FIELDS: Record = { 0: ["name"], 1: [], 2: ["frequencyType", "intervalMinutes", "cronExpression", "timezone"], 3: ["severityThreshold", "notificationEmail", "consent"], 4: [], }; function splitNames(value: string | undefined): string[] { return (value ?? "") .split(",") .map((v) => v.trim()) .filter(Boolean); } const MONITORING_FOCUS_PLACEHOLDER = "E.g. new product or pricing changes, leadership hires/departures, hiring trends, " + "funding/financial signals, partnerships and acquisitions, patents, manufacturing or " + "expansion moves, regulatory or legal news, and how they compare to competitors."; function AddCompanyWizard() { const router = useRouter(); const searchParams = useSearchParams(); const prefillName = searchParams.get("name") ?? ""; const returnTo = searchParams.get("returnTo"); const [step, setStep] = useState(0); const [discovery, setDiscovery] = useState(null); const [isFinalizing, setIsFinalizing] = useState(false); const [duplicateWarning, setDuplicateWarning] = useState<{ company: CompanyResponse; forName: string; } | null>(null); const [acknowledgedDuplicateFor, setAcknowledgedDuplicateFor] = useState(null); const { data: allCompanies } = useCompanies(); const createCompany = useCreateCompany(); const createDestination = useCreateNotificationDestination(); const discoverCompany = useDiscoverCompany(); const { data: systemStatus } = useSystemStatus(); const { register, handleSubmit, control, watch, setValue, getValues, trigger, formState: { errors }, } = useForm({ resolver: zodResolver(companySchema), defaultValues: { name: prefillName, frequencyType: "weekly", timezone: "America/New_York", severityThreshold: "medium", }, }); const frequencyType = watch("frequencyType"); const goNext = async () => { const valid = await trigger(STEP_FIELDS[step]); if (valid) setStep((s) => Math.min(s + 1, STEPS.length - 1)); }; const goBack = () => { if (step === 0) { // Nothing earlier in the wizard to step back to - leave to wherever // the user actually came from (Dashboard, Companies, or a rival // company's page via the "add this competitor" link). A `returnTo` // param (set by the company page's competitor/customer pills) is // preferred over plain browser-history back, since a client // component's local state (e.g. which tab was open) doesn't reliably // survive a back-navigation in the App Router. if (returnTo) { router.push(returnTo); return; } router.back(); return; } setStep((s) => Math.max(s - 1, 0)); }; const handleDiscover = async () => { const valid = await trigger(["name"]); if (!valid) return; const values = getValues(); if (acknowledgedDuplicateFor !== values.name) { const possibleDuplicate = findPossibleDuplicate(values.name, allCompanies ?? []); if (possibleDuplicate) { setDuplicateWarning({ company: possibleDuplicate, forName: values.name }); return; } } setDuplicateWarning(null); const profile = await discoverCompany.mutateAsync({ name: values.name, official_website: values.officialWebsite || undefined, monitoring_focus: values.monitoringFocus || undefined, competitor_names: splitNames(values.competitorNames), alias_names: splitNames(values.aliasNames), }); setDiscovery(profile); setValue("officialWebsite", profile.official_website ?? ""); setValue("description", profile.description ?? ""); setValue("industry", profile.industry ?? ""); setValue("country", profile.country ?? ""); setValue("region", profile.region ?? ""); setValue("headquarters", profile.headquarters ?? ""); setValue("monitoringFocus", profile.monitoring_focus ?? values.monitoringFocus ?? ""); setValue("competitorNames", profile.competitors.join(", ")); setValue("aliasNames", profile.aliases.join(", ")); setStep(1); }; const onSubmit = handleSubmit(async (values) => { setIsFinalizing(true); try { const company = await createCompany.mutateAsync({ name: values.name, official_website: values.officialWebsite || null, industry: values.industry || null, country: values.country || null, region: values.region || null, headquarters: values.headquarters || null, public_identifiers: discovery?.public_identifiers ?? {}, description: values.description || null, monitoring_focus: values.monitoringFocus || null, competitor_names: splitNames(values.competitorNames), alias_names: splitNames(values.aliasNames), frequency_type: values.frequencyType, interval_minutes: values.frequencyType === "custom" ? (values.intervalMinutes ?? null) : null, cron_expression: values.frequencyType === "custom" ? values.cronExpression || null : null, timezone: values.timezone, severity_threshold: values.severityThreshold, }); await createDestination .mutateAsync({ type: "email", destination_value: values.notificationEmail, company_ids: [company.id], }) .catch(() => undefined); // Best-effort; company creation still succeeded either way. // Best-effort, same as the destination above - a failed enqueue here // shouldn't block landing on the new company's page, it just means // the user sees an idle Run Now button instead of the running state. await api.runCompanyNow(company.id).catch(() => undefined); router.push(`/companies/${company.id}`); } catch { setIsFinalizing(false); } }); return (

Add a company

Give us a name — we'll find the rest and let you correct anything before we start monitoring.

    {STEPS.map((label, i) => (
  1. {i + 1} {label} {i < STEPS.length - 1 && /}
  2. ))}
{step === 0 && (
{duplicateWarning && duplicateWarning.forName === watch("name") && (

You might already be monitoring{" "} "{duplicateWarning.company.name}". Adding another with a similar name is fine, but double-check it's not the same company.

View existing company
)}

Optional — helps discovery be more accurate