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]>
692 lines
27 KiB
TypeScript
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'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>"{duplicateWarning.company.name}"</strong>. Adding another
|
|
with a similar name is fine, but double-check it'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's what we found for <strong>{watch("name")}</strong>. Edit anything before
|
|
continuing — fields we couldn'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'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'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>
|
|
);
|
|
}
|