From 3e0d2110c6e16a8b505914daaeb89ba72764bc82 Mon Sep 17 00:00:00 2001 From: "Victor \"David\" Medina" Date: Sat, 27 Jun 2026 10:29:24 -0400 Subject: [PATCH] feat(C-P1-S1): first brief from your website in the Starter wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Customer value: a new owner sees a brief recognizably built from THEIR own business in the first ~60s — the biggest 'this is real, not a setup form' moment, and the exact onboarding friction Elaine hit. Wires an ALREADY-BUILT-but-orphaned scrape: one optional, skippable 'Your website' field in StarterOnboardingWizard step 1 (never blocks Continue — the 2-min speed is preserved), and on completeOnboarding it non-fatally calls the EXISTING persistScrapeInputs({companyUrl}) server action → profile_onboarding_private.company_url → the already-shipped BriefBuildingState on /focus auto-fires the bootstrap. Honest + safe: persistScrapeInputs validates SSRF + returns {ok:false} quietly, so a bad/empty URL just lands on the honest sample brief. The 'why it's safe' line is explicit ('we only read your public website; nothing is sent to your clients'). Anti-theater-safe (no new motion), no new backend, no new send path. tsc: 0 errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../onboarding/StarterOnboardingWizard.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/app/(shell)/onboarding/StarterOnboardingWizard.tsx b/app/(shell)/onboarding/StarterOnboardingWizard.tsx index 5dd9a29b1..406c30bd1 100644 --- a/app/(shell)/onboarding/StarterOnboardingWizard.tsx +++ b/app/(shell)/onboarding/StarterOnboardingWizard.tsx @@ -25,6 +25,7 @@ import { type IntegrationStatus, } from "@/components/integrations/ConnectToolsWizard"; import { cn } from "@/lib/utils/cn"; +import { persistScrapeInputs } from "./intake/actions"; import { getOutcomeLabelWithFallback } from "@/lib/data/vertical-outcomes"; import { mapBusinessTypeToVertical, @@ -185,6 +186,7 @@ export function StarterOnboardingWizard({ const router = useRouter(); const [step, setStep] = useState(initialStep); const [businessType, setBusinessType] = useState(null); + const [companyUrl, setCompanyUrl] = useState(""); const [quickWins, setQuickWins] = useState([]); const [actionError, setActionError] = useState(null); const [workspacePending, setWorkspacePending] = useState(false); @@ -293,6 +295,19 @@ export function StarterOnboardingWizard({ if (!response.ok) { throw new Error("We could not finish Starter setup yet. Please try again."); } + + // P1-S1: if the owner gave their website, persist it so /focus drafts the first + // brief FROM their own business (the BriefBuildingState on /focus auto-fires the + // bootstrap). Non-fatal by design: an empty/invalid URL just lands on the honest + // sample brief (persistScrapeInputs validates SSRF + returns {ok:false} quietly). + const trimmedUrl = companyUrl.trim(); + if (trimmedUrl) { + try { + await persistScrapeInputs({ companyUrl: trimmedUrl }); + } catch { + // never block onboarding on the optional scrape + } + } }; const provisionTenant = async (): Promise<"ready" | "pending"> => { @@ -428,6 +443,7 @@ export function StarterOnboardingWizard({
{step === 1 && ( +
{BUSINESS_OPTIONS.map(({ id, icon: Icon, label, hint }) => { const selected = businessType === id; @@ -462,6 +478,26 @@ export function StarterOnboardingWizard({ ); })}
+
+ + setCompanyUrl(event.target.value)} + placeholder="yourbusiness.com" + className="w-full rounded-lg border border-stone-700 bg-stone-950/70 px-3.5 py-2.5 text-sm text-stone-100 placeholder:text-stone-500 focus-visible:border-amber-400/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/30" + /> +

+ We only read your public website to draft a first brief, nothing is sent to your clients. +

+
+
)} {step === 2 && (