Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,680 @@
|
||||
"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<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 {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
getValues,
|
||||
trigger,
|
||||
formState: { errors },
|
||||
} = useForm<CompanyForm>({
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user