Skip to content

Commit 70434e8

Browse files
committed
feat: add structured intake forms for high-value marketing workflows
Structured intake forms for homepage rewrite, outbound sequence, and launch pack workflows. Clicking these job cards now opens a lightweight Dialog-based form that collects targeted inputs (target buyer, tone, timing, etc.) with company context pre-population from workspace summary. Form values are merged into the specialist prompt via a new buildActionPromptWithIntake() function, producing better-tailored outputs than a blank-prompt start.
1 parent fc28ea9 commit 70434e8

File tree

7 files changed

+835
-2
lines changed

7 files changed

+835
-2
lines changed

apps/desktop/src/features/dashboard/components/DashboardWorkspace.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import { LayoutDashboardIcon } from "lucide-react";
2-
import { useMemo } from "react";
2+
import { useCallback, useMemo } from "react";
33
import type { AgentSession, ArtifactRecord } from "@opengoat/contracts";
44
import type { SidecarClient } from "@/lib/sidecar/client";
55
import { resolveDomain, buildFaviconSources } from "@/lib/utils/favicon";
66
import { getActionMapping } from "@/lib/utils/action-map";
77
import { useProjectMaturity } from "@/features/dashboard/hooks/useProjectMaturity";
88
import { usePlaybookLauncher } from "@/features/dashboard/hooks/usePlaybookLauncher";
9+
import { useIntakeForm } from "@/features/dashboard/hooks/useIntakeForm";
910
import { ContinueWhereYouLeftOff } from "@/features/dashboard/components/ContinueWhereYouLeftOff";
1011
import { CompanyUnderstandingHero } from "@/features/dashboard/components/CompanyUnderstandingHero";
1112
import { DashboardAgentRoster } from "@/features/dashboard/components/DashboardAgentRoster";
1213
import { PlaybookInputForm } from "@/features/dashboard/components/PlaybookInputForm";
14+
import { IntakeFormDialog } from "@/features/dashboard/components/IntakeFormDialog";
1315
import { RecommendedJobs } from "@/features/dashboard/components/RecommendedJobs";
1416
import { JobOrientedInput } from "@/features/dashboard/components/JobOrientedInput";
1517
import { useMeaningfulWork } from "@/features/dashboard/hooks/useMeaningfulWork";
@@ -138,6 +140,13 @@ function DashboardContent({
138140
// ── Maturity detection: controls section visibility ──
139141
const maturity = useProjectMaturity(meaningfulWork, runsResult, boardSummary, actionSessions);
140142

143+
// ── Intake form — intercepts clicks for actions with structured intake fields ──
144+
const onSubmitAction = useCallback(
145+
(actionId: string, prompt: string, label: string) => playbook.handleActionOrPlaybookClick(actionId, prompt, label),
146+
[playbook],
147+
);
148+
const intake = useIntakeForm({ suggestedActions, onSubmitAction });
149+
141150
function handleFreeTextSubmit(text: string) {
142151
onActionClick?.("free-text", text, text.slice(0, 50));
143152
}
@@ -177,7 +186,7 @@ function DashboardContent({
177186
<RecommendedJobs
178187
jobs={recommendedJobs.jobs}
179188
isLoading={recommendedJobs.isLoading}
180-
onActionClick={(id, prompt, label) => { void playbook.handleActionOrPlaybookClick(id, prompt, label); }}
189+
onActionClick={intake.handleJobCardClick}
181190
/>
182191
</div>
183192

@@ -238,6 +247,20 @@ function DashboardContent({
238247
onSubmit={playbook.handlePlaybookFormSubmit}
239248
/>
240249
)}
250+
251+
{/* Structured intake form dialog */}
252+
{intake.pendingIntakeFields && intake.pendingIntakeAction && (
253+
<IntakeFormDialog
254+
open={intake.intakeFormOpen}
255+
onOpenChange={(open) => { if (!open) intake.closeIntakeForm(); }}
256+
actionTitle={intake.pendingIntakeAction.title}
257+
outputType={intake.pendingIntakeAction.outputType}
258+
specialistName={intake.pendingIntakeAction.specialistId}
259+
fields={intake.pendingIntakeFields}
260+
companyData={data}
261+
onSubmit={intake.handleIntakeFormSubmit}
262+
/>
263+
)}
241264
</div>
242265
);
243266
}
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { useState, useCallback, useEffect } from "react";
2+
import { ArrowRightIcon, LoaderCircleIcon, ClipboardListIcon } from "lucide-react";
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
Dialog,
6+
DialogContent,
7+
DialogDescription,
8+
DialogFooter,
9+
DialogHeader,
10+
DialogTitle,
11+
} from "@/components/ui/dialog";
12+
import { Input } from "@/components/ui/input";
13+
import { Textarea } from "@/components/ui/textarea";
14+
import {
15+
Select,
16+
SelectContent,
17+
SelectItem,
18+
SelectTrigger,
19+
SelectValue,
20+
} from "@/components/ui/select";
21+
import type { IntakeFieldSet } from "@/features/dashboard/data/intake-fields";
22+
import type { CompanySummaryData } from "@/features/dashboard/lib/parse-workspace-summary";
23+
24+
export interface IntakeFormDialogProps {
25+
open: boolean;
26+
onOpenChange: (open: boolean) => void;
27+
actionTitle: string;
28+
outputType?: string;
29+
specialistName?: string;
30+
fields: IntakeFieldSet;
31+
companyData: CompanySummaryData | null;
32+
isSubmitting?: boolean;
33+
onSubmit: (values: Record<string, string>) => void;
34+
}
35+
36+
export function IntakeFormDialog({
37+
open,
38+
onOpenChange,
39+
actionTitle,
40+
outputType,
41+
specialistName,
42+
fields,
43+
companyData,
44+
isSubmitting = false,
45+
onSubmit,
46+
}: IntakeFormDialogProps) {
47+
const [values, setValues] = useState<Record<string, string>>({});
48+
49+
// Pre-populate from company data when dialog opens
50+
useEffect(() => {
51+
if (!open) return;
52+
const prefilled: Record<string, string> = {};
53+
const allFields = [...fields.required, ...fields.optional];
54+
for (const field of allFields) {
55+
if (field.prefillFrom && companyData) {
56+
const value = companyData[field.prefillFrom];
57+
if (value) prefilled[field.key] = value;
58+
}
59+
}
60+
setValues(prefilled);
61+
}, [open, fields, companyData]);
62+
63+
const allRequiredFilled = fields.required.every(
64+
(f) => (values[f.key] ?? "").trim().length > 0,
65+
);
66+
67+
const handleChange = useCallback((key: string, value: string) => {
68+
setValues((prev) => ({ ...prev, [key]: value }));
69+
}, []);
70+
71+
const handleSubmit = useCallback(
72+
(e: React.FormEvent) => {
73+
e.preventDefault();
74+
if (!allRequiredFilled || isSubmitting) return;
75+
const result: Record<string, string> = {};
76+
for (const [key, val] of Object.entries(values)) {
77+
if (val.trim()) result[key] = val.trim();
78+
}
79+
onSubmit(result);
80+
},
81+
[allRequiredFilled, isSubmitting, values, onSubmit],
82+
);
83+
84+
return (
85+
<Dialog open={open} onOpenChange={onOpenChange}>
86+
<DialogContent className="sm:max-w-md">
87+
<form onSubmit={handleSubmit}>
88+
<DialogHeader>
89+
<DialogTitle className="flex items-center gap-2">
90+
<ClipboardListIcon className="size-4 text-primary" />
91+
{actionTitle}
92+
</DialogTitle>
93+
<DialogDescription>
94+
{outputType && (
95+
<span className="mr-1.5 inline-flex items-center rounded-md border border-primary/15 bg-primary/[0.06] px-2 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-wider text-primary dark:border-primary/10 dark:bg-primary/[0.08]">
96+
{outputType}
97+
</span>
98+
)}
99+
{specialistName ? (
100+
<>A few details to tailor the work by {specialistName}.</>
101+
) : (
102+
<>A few details so the output matches your needs.</>
103+
)}
104+
</DialogDescription>
105+
</DialogHeader>
106+
107+
<div className="flex flex-col gap-3 py-4">
108+
{fields.required.map((field, i) => (
109+
<FieldRenderer
110+
key={field.key}
111+
field={field}
112+
value={values[field.key] ?? ""}
113+
onChange={handleChange}
114+
disabled={isSubmitting}
115+
autoFocus={i === 0}
116+
showRequired
117+
/>
118+
))}
119+
120+
{fields.optional.length > 0 && (
121+
<>
122+
<div className="mt-1 border-t border-border/30 pt-2">
123+
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/60">
124+
Optional
125+
</span>
126+
</div>
127+
{fields.optional.map((field) => (
128+
<FieldRenderer
129+
key={field.key}
130+
field={field}
131+
value={values[field.key] ?? ""}
132+
onChange={handleChange}
133+
disabled={isSubmitting}
134+
/>
135+
))}
136+
</>
137+
)}
138+
</div>
139+
140+
<DialogFooter>
141+
<Button
142+
type="submit"
143+
disabled={!allRequiredFilled || isSubmitting}
144+
className="gap-2"
145+
>
146+
{isSubmitting ? (
147+
<>
148+
<LoaderCircleIcon className="size-3.5 animate-spin" />
149+
Starting...
150+
</>
151+
) : (
152+
<>
153+
<ArrowRightIcon className="size-3.5" />
154+
Start
155+
</>
156+
)}
157+
</Button>
158+
</DialogFooter>
159+
</form>
160+
</DialogContent>
161+
</Dialog>
162+
);
163+
}
164+
165+
// ---------------------------------------------------------------------------
166+
// Field renderer — renders the appropriate input type based on field schema
167+
// ---------------------------------------------------------------------------
168+
169+
function FieldRenderer({
170+
field,
171+
value,
172+
onChange,
173+
disabled,
174+
autoFocus,
175+
showRequired,
176+
}: {
177+
field: { key: string; label: string; type: "text" | "textarea" | "select"; placeholder: string; options?: string[] };
178+
value: string;
179+
onChange: (key: string, value: string) => void;
180+
disabled?: boolean;
181+
autoFocus?: boolean;
182+
showRequired?: boolean;
183+
}) {
184+
const labelEl = (
185+
<span className={`text-[13px] font-medium ${showRequired ? "text-foreground" : "text-muted-foreground"}`}>
186+
{field.label}
187+
{showRequired && <span className="ml-0.5 text-destructive">*</span>}
188+
</span>
189+
);
190+
191+
if (field.type === "select" && field.options) {
192+
return (
193+
<label className="flex flex-col gap-1.5">
194+
{labelEl}
195+
<Select
196+
value={value || undefined}
197+
onValueChange={(v) => onChange(field.key, v)}
198+
disabled={disabled}
199+
>
200+
<SelectTrigger className="w-full">
201+
<SelectValue placeholder={field.placeholder} />
202+
</SelectTrigger>
203+
<SelectContent>
204+
{field.options.map((opt) => (
205+
<SelectItem key={opt} value={opt}>
206+
{opt}
207+
</SelectItem>
208+
))}
209+
</SelectContent>
210+
</Select>
211+
</label>
212+
);
213+
}
214+
215+
if (field.type === "textarea") {
216+
return (
217+
<label className="flex flex-col gap-1.5">
218+
{labelEl}
219+
<Textarea
220+
value={value}
221+
onChange={(e) => onChange(field.key, e.target.value)}
222+
placeholder={field.placeholder}
223+
disabled={disabled}
224+
className="min-h-[60px]"
225+
/>
226+
</label>
227+
);
228+
}
229+
230+
return (
231+
<label className="flex flex-col gap-1.5">
232+
{labelEl}
233+
<Input
234+
value={value}
235+
onChange={(e) => onChange(field.key, e.target.value)}
236+
placeholder={field.placeholder}
237+
disabled={disabled}
238+
autoFocus={autoFocus}
239+
/>
240+
</label>
241+
);
242+
}

apps/desktop/src/features/dashboard/data/actions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export interface ActionCard {
3030
outputType?: string;
3131
/** Outcome-based CTA text, e.g. "Get rewrites". Falls back to "Start". */
3232
ctaLabel?: string;
33+
/** Key into intakeFieldRegistry — when set, clicking the job opens a structured intake form */
34+
intakeFields?: string;
3335
}
3436

3537
/**
@@ -112,6 +114,7 @@ export const starterActions: ActionCard[] = [
112114
promise: "Tagline, one-liner, launch post draft, checklist, and outreach angles",
113115
outputType: "Launch copy bundle",
114116
ctaLabel: "Build launch pack",
117+
intakeFields: "launch-product-hunt",
115118
description:
116119
"Creates ready-to-use Product Hunt launch assets including tagline options, description variants, first comment drafts, and maker story angles.",
117120
icon: RocketIcon,
@@ -139,6 +142,7 @@ Produce:
139142
promise: "3 hero variants, CTA options, and trust copy ideas",
140143
outputType: "3 hero rewrites",
141144
ctaLabel: "Get rewrites",
145+
intakeFields: "rewrite-homepage-hero",
142146
description:
143147
"Analyzes your current homepage hero section and generates improved headline, subheadline, and CTA variants that better communicate your value proposition.",
144148
icon: MessageSquareIcon,
@@ -189,6 +193,7 @@ Produce:
189193
promise: "Audience angle, email sequence, subject lines, and follow-up ideas",
190194
outputType: "4-email sequence",
191195
ctaLabel: "Build sequence",
196+
intakeFields: "build-outbound-sequence",
192197
description:
193198
"Creates a complete cold email outreach sequence using proven copy frameworks, with subject lines, body copy, follow-up timing, and personalization strategies.",
194199
icon: MailIcon,

0 commit comments

Comments
 (0)