Files
sakshamandClaude Sonnet 5 6343ea19db Don't prefill notification email for the local-dev bypass account
[email protected] is a fixed, non-deliverable placeholder (no mail
relay serves that domain, and Mailpit was removed a while back) - prefilling
it was just noise the local dev would always need to clear. Real accounts
still get their own email prefilled as before.

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

698 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 { isLocalConvenience } from "@/lib/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. Skipped for the local-dev
// bypass account: its email is a fixed, non-deliverable placeholder
// ([email protected] - no mail relay actually serves that domain),
// not something worth prefilling - local dev just types whatever real
// address they want to test with.
const isLocalDev = systemStatus ? isLocalConvenience(systemStatus) : false;
useEffect(() => {
if (currentUser?.email && !isLocalDev && !dirtyFields.notificationEmail) {
setValue("notificationEmail", currentUser.email);
}
}, [currentUser?.email, isLocalDev, 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>
);
}