Files
CIAgent/apps/web/app/(app)/companies/new/page.tsx
T
sakshamandClaude Sonnet 5 5b450f22cf Prefill notification email in Add Company wizard with the account's own email
Was always blank by default. Now prefilled from useCurrentUser() once it
loads (defaultValues can't do this since the query resolves after mount),
gated on the field not being dirty yet so it never clobbers something the
user already typed - still fully editable either way.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-05 21:07:32 -04:00

692 lines
27 KiB
TypeScript

"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, 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, useCurrentUser, 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<typeof companySchema>;
const STEPS = ["Discover", "Review", "Schedule", "Notifications", "Confirm"] as const;
const STEP_FIELDS: Record<number, (keyof CompanyForm)[]> = {
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<DiscoveredCompanyProfile | null>(null);
const [isFinalizing, setIsFinalizing] = useState(false);
const [duplicateWarning, setDuplicateWarning] = useState<{
company: CompanyResponse;
forName: string;
} | null>(null);
const [acknowledgedDuplicateFor, setAcknowledgedDuplicateFor] = useState<string | null>(null);
const { data: allCompanies } = useCompanies();
const createCompany = useCreateCompany();
const createDestination = useCreateNotificationDestination();
const discoverCompany = useDiscoverCompany();
const { data: systemStatus } = useSystemStatus();
const { data: currentUser } = useCurrentUser();
const {
register,
handleSubmit,
control,
watch,
setValue,
getValues,
trigger,
formState: { errors, dirtyFields },
} = useForm<CompanyForm>({
resolver: zodResolver(companySchema),
defaultValues: {
name: prefillName,
frequencyType: "weekly",
timezone: "America/New_York",
severityThreshold: "medium",
},
});
// Prefill with the account's own email once it loads - `defaultValues`
// can't do this since useCurrentUser resolves after the form has already
// mounted. Only sets it while the field is still untouched, so it never
// clobbers something the user already typed.
useEffect(() => {
if (currentUser?.email && !dirtyFields.notificationEmail) {
setValue("notificationEmail", currentUser.email);
}
}, [currentUser?.email, dirtyFields.notificationEmail, setValue]);
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 (
<div className="mx-auto max-w-2xl">
<h1 className="text-2xl font-semibold text-slate-900">Add a company</h1>
<p className="mt-1 text-sm text-slate-600">
Give us a name we&apos;ll find the rest and let you correct anything before we start
monitoring.
</p>
<ol className="mt-6 flex items-center gap-2 text-xs font-medium text-slate-500">
{STEPS.map((label, i) => (
<li
key={label}
className={`flex items-center gap-2 transition-colors duration-300 ${i === step ? "text-brand-600" : ""}`}
>
<span
className={`flex h-5 w-5 items-center justify-center rounded-full border text-[11px] transition-all duration-300 ${
i === step
? "scale-110 border-brand-600 bg-brand-600 text-white"
: i < step
? "border-brand-600 text-brand-600"
: "border-slate-300"
}`}
>
{i + 1}
</span>
{label}
{i < STEPS.length - 1 && <span className="mx-1 text-slate-300">/</span>}
</li>
))}
</ol>
<form
onSubmit={onSubmit}
className="mt-6 space-y-5 rounded-lg border border-slate-200 bg-white p-6"
noValidate
>
<div key={step} className="animate-fade-in space-y-5">
{step === 0 && (
<div className="space-y-4">
<FormField label="Company name" {...register("name")} error={errors.name?.message} />
{duplicateWarning && duplicateWarning.forName === watch("name") && (
<div className="animate-fade-in rounded-md border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900">
<p>
You might already be monitoring{" "}
<strong>&quot;{duplicateWarning.company.name}&quot;</strong>. Adding another
with a similar name is fine, but double-check it&apos;s not the same company.
</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<Link
href={`/companies/${duplicateWarning.company.id}`}
className="focus-ring rounded-md border border-amber-400 bg-white px-3 py-1.5 text-xs font-semibold text-amber-900 hover:bg-amber-100"
>
View existing company
</Link>
<button
type="button"
onClick={() => {
setAcknowledgedDuplicateFor(duplicateWarning.forName);
setDuplicateWarning(null);
}}
className="focus-ring rounded-md px-3 py-1.5 text-xs font-semibold text-amber-900 underline decoration-amber-400 underline-offset-2 hover:bg-amber-100"
>
Continue anyway
</button>
</div>
</div>
)}
<div className="rounded-md border border-dashed border-slate-300 p-4">
<p className="text-sm font-medium text-slate-700">
Optional helps discovery be more accurate
</p>
<div className="mt-3 space-y-4">
<FormField
label="Official website"
placeholder="example.com"
hint="If you know it, we'll skip searching for it."
{...register("officialWebsite")}
error={errors.officialWebsite?.message}
/>
<div>
<label
htmlFor="monitoringFocus"
className="block text-sm font-medium text-slate-700"
>
What do you want to know?
</label>
<textarea
id="monitoringFocus"
rows={3}
placeholder={MONITORING_FOCUS_PLACEHOLDER}
className="focus-ring mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm"
{...register("monitoringFocus")}
/>
</div>
<FormField
label="Known competitors (comma-separated)"
placeholder="Rival Motors, Other Corp"
{...register("competitorNames")}
/>
<FormField
label="Known aliases (comma-separated)"
placeholder="Brand name, subsidiary name"
{...register("aliasNames")}
/>
</div>
</div>
{discoverCompany.isError && (
<p className="text-sm text-red-600" role="alert">
{authErrorMessage(discoverCompany.error)}
</p>
)}
</div>
)}
{step === 1 && (
<div className="space-y-4">
<p className="text-sm text-slate-600">
Here&apos;s what we found for <strong>{watch("name")}</strong>. Edit anything before
continuing fields we couldn&apos;t find are left blank for you to fill in.
</p>
<FormField
label="Official website"
placeholder="example.com"
{...register("officialWebsite")}
error={errors.officialWebsite?.message}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
label="Industry"
placeholder="Not found — fill in"
{...register("industry")}
/>
<FormField
label="Headquarters"
placeholder="Not found — fill in"
{...register("headquarters")}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
label="Country"
placeholder="Not found — fill in"
{...register("country")}
/>
<FormField
label="Region"
placeholder="Not found — fill in"
{...register("region")}
/>
</div>
<FormField
label="Competitors (comma-separated)"
placeholder="Rival Motors, Other Corp"
{...register("competitorNames")}
/>
<FormField
label="Known aliases (comma-separated)"
placeholder="Brand name, subsidiary name"
{...register("aliasNames")}
/>
<div>
<label htmlFor="description" className="block text-sm font-medium text-slate-700">
Description
</label>
<textarea
id="description"
rows={2}
placeholder="Not found — fill in"
className="focus-ring mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm"
{...register("description")}
/>
</div>
{discovery && discovery.potential_sources.length > 0 && (
<div className="rounded-md bg-slate-50 p-4">
<p className="text-sm font-medium text-slate-700">
We&apos;ll start monitoring {discovery.potential_sources.length} source
{discovery.potential_sources.length === 1 ? "" : "s"}
</p>
<ul className="mt-2 space-y-1 text-xs text-slate-600">
{discovery.potential_sources.map((source, i) => (
<li key={i}>
{source.name} {source.base_url ?? "n/a"}
</li>
))}
</ul>
</div>
)}
{discovery && Object.keys(discovery.public_identifiers).length > 0 && (
<p className="text-xs text-slate-500">
{Object.entries(discovery.public_identifiers).map(([key, value]) => (
<span key={key} className="mr-3">
{key}: {value}
</span>
))}
</p>
)}
{discovery && discovery.sources_consulted.length > 0 && (
<p className="text-xs text-slate-400">
Sources consulted: {discovery.sources_consulted.join(", ")}
</p>
)}
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700">
Monitoring frequency
</label>
<div className="mt-1">
<Select
value={frequencyType}
onValueChange={(v) => setValue("frequencyType", v as MonitoringFrequency)}
options={MONITORING_FREQUENCIES.map((f: MonitoringFrequency) => ({
value: f,
label: FREQUENCY_LABELS[f],
}))}
triggerClassName="w-full px-3 py-2 text-sm"
/>
</div>
</div>
{frequencyType === "custom" && (
<div className="grid grid-cols-2 gap-4">
<FormField
label="Interval (minutes)"
type="number"
{...register("intervalMinutes")}
error={errors.intervalMinutes?.message}
/>
<FormField
label="Cron expression (advanced)"
placeholder="0 9 * * MON"
{...register("cronExpression")}
/>
</div>
)}
<FormField
label="Timezone"
hint="IANA timezone name, e.g. America/New_York"
{...register("timezone")}
error={errors.timezone?.message}
/>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700">
Alert severity threshold
</label>
<div className="mt-1">
<Select
value={watch("severityThreshold")}
onValueChange={(v) => setValue("severityThreshold", v as SeverityLevel)}
options={SEVERITY_LEVELS.map((s: SeverityLevel) => ({
value: s,
label: SEVERITY_LABELS[s],
}))}
triggerClassName="w-full px-3 py-2 text-sm"
/>
</div>
<p className="mt-1 text-xs text-slate-500">
You&apos;ll be notified for this severity and above.
</p>
</div>
<FormField
label="Notification email"
type="email"
{...register("notificationEmail")}
error={errors.notificationEmail?.message}
/>
<Controller
name="consent"
control={control}
render={({ field }) => (
<label className="flex items-start gap-2 text-sm text-slate-700">
<input
type="checkbox"
className="mt-0.5"
checked={field.value === true}
onChange={(e) => field.onChange(e.target.checked)}
/>
<span>
I understand CI Agent only collects publicly available information and will
never bypass logins, CAPTCHAs, paywalls, or anti-bot controls.
</span>
</label>
)}
/>
{errors.consent && (
<p className="text-sm text-red-600" role="alert">
{errors.consent.message}
</p>
)}
</div>
)}
{step === 4 && isFinalizing && (
<div className="flex flex-col items-center justify-center gap-3 py-10 text-sm text-slate-600">
<Loader2 className="h-6 w-6 animate-spin text-brand-600" aria-hidden />
<p>Setting up monitoring for {watch("name")}</p>
</div>
)}
{step === 4 && !isFinalizing && (
<div className="space-y-2 text-sm">
<p>
<span className="font-medium text-slate-700">Company:</span> {watch("name")}
</p>
<p>
<span className="font-medium text-slate-700">Website:</span>{" "}
{watch("officialWebsite") || "—"}
</p>
<p>
<span className="font-medium text-slate-700">Industry:</span>{" "}
{watch("industry") || "—"}
</p>
<p>
<span className="font-medium text-slate-700">Headquarters:</span>{" "}
{watch("headquarters") || "—"}
</p>
<p>
<span className="font-medium text-slate-700">Region:</span>{" "}
{[watch("country"), watch("region")].filter(Boolean).join(", ") || "—"}
</p>
<p>
<span className="font-medium text-slate-700">Competitors:</span>{" "}
{watch("competitorNames") || "—"}
</p>
<p>
<span className="font-medium text-slate-700">Aliases:</span>{" "}
{watch("aliasNames") || "—"}
</p>
<p>
<span className="font-medium text-slate-700">Focus:</span>{" "}
{watch("monitoringFocus") || "—"}
</p>
<p>
<span className="font-medium text-slate-700">Frequency:</span>{" "}
{FREQUENCY_LABELS[watch("frequencyType")]}
</p>
<p>
<span className="font-medium text-slate-700">Severity threshold:</span>{" "}
{SEVERITY_LABELS[watch("severityThreshold")]}
</p>
<p>
<span className="font-medium text-slate-700">Email:</span>{" "}
{watch("notificationEmail")}
</p>
{systemStatus?.ninjapear_configured && (
<p className="rounded-md bg-slate-50 p-3 text-xs text-slate-600">
NinjaPear company enrichment is on:{" "}
{systemStatus.ninjapear_credit_balance !== null
? `${systemStatus.ninjapear_credit_balance} credits remaining, `
: ""}
this company will use up to ~
{systemStatus.ninjapear_estimated_credits_per_company ?? "?"} credits.
</p>
)}
{createCompany.isError && (
<p className="text-sm text-red-600" role="alert">
{authErrorMessage(createCompany.error)}
</p>
)}
</div>
)}
</div>
<div className="flex items-center justify-between border-t border-slate-100 pt-4">
<button
type="button"
onClick={goBack}
disabled={isFinalizing}
className="focus-ring rounded-md px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-100 disabled:opacity-40"
>
Back
</button>
{step === 0 ? (
<button
type="button"
onClick={handleDiscover}
disabled={discoverCompany.isPending}
className="focus-ring inline-flex items-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-60"
>
{discoverCompany.isPending && (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
)}
{discoverCompany.isPending ? "Discovering…" : "Discover company"}
</button>
) : step < STEPS.length - 1 ? (
<button
type="button"
onClick={goNext}
className="focus-ring rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700"
>
Continue
</button>
) : (
<button
type="submit"
disabled={isFinalizing}
className="focus-ring rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-60"
>
{isFinalizing ? "Creating…" : "Create company"}
</button>
)}
</div>
</form>
</div>
);
}
export default function AddCompanyWizardPage() {
return (
<Suspense fallback={null}>
<AddCompanyWizard />
</Suspense>
);
}