File: /home/xedaptot/ai.naniguide.com/app/Services/Form/FormFieldCatalog.php
<?php
namespace App\Services\Form;
/**
* F4.4 — Static catalog of field-recommendation presets per signup intent.
*
* Runtime SoT for `form.suggest_fields`. Mirrors `form.json` →
* `entities.field_recommendations[]`; the F38 Pest test asserts every
* preset id here appears in the KB and vice-versa (D80 dual-source
* dual-source pattern: PHP catalog drives runtime, KB mirrors prompt-side).
*/
class FormFieldCatalog
{
/**
* @return list<array{
* id:string,
* label:string,
* summary:string,
* recommended_fields:list<array{tag:string,label:string,type:string,required:bool,rationale:string}>,
* optional_fields:list<array{tag:string,label:string,type:string,required:bool,rationale:string}>,
* avoid_fields:list<string>,
* max_visible_fields:int
* }>
*/
public static function presets(): array
{
return [
[
'id' => 'newsletter',
'label' => 'Newsletter signup',
'summary' => 'Recurring marketing emails. Conversion is the goal; fewer fields = better. Email-only is a valid baseline.',
'recommended_fields' => [
['tag' => 'EMAIL', 'label' => 'Email', 'type' => 'email', 'required' => true, 'rationale' => 'Required for any signup. Every list has exactly one email field.'],
['tag' => 'FIRST_NAME', 'label' => 'First name', 'type' => 'text', 'required' => false, 'rationale' => 'Worth collecting only if you personalise email greetings. Optional, not required.'],
],
'optional_fields' => [
['tag' => 'INTERESTS', 'label' => 'Topics of interest', 'type' => 'select', 'required' => false, 'rationale' => 'Use only if you actually segment by interest. Otherwise it\'s overcollection.'],
],
'avoid_fields' => ['PHONE', 'ADDRESS', 'COMPANY', 'LAST_NAME', 'JOB_TITLE', 'BIRTHDAY'],
'max_visible_fields' => 3,
],
[
'id' => 'lead_gen',
'label' => 'Lead generation (general)',
'summary' => 'Sales follow-up. More fields = better lead quality, but each field cuts conversion. Aim for 3 visible fields.',
'recommended_fields' => [
['tag' => 'EMAIL', 'label' => 'Work email', 'type' => 'email', 'required' => true, 'rationale' => 'Work email signals a real lead. Generic free providers (gmail, hotmail) self-segment as lower-priority.'],
['tag' => 'FIRST_NAME', 'label' => 'First name', 'type' => 'text', 'required' => true, 'rationale' => 'Sales outreach needs a name to address. Required.'],
['tag' => 'COMPANY', 'label' => 'Company', 'type' => 'text', 'required' => true, 'rationale' => 'Lets sales pre-qualify before outreach. Required for B2B.'],
],
'optional_fields' => [
['tag' => 'PHONE', 'label' => 'Phone (optional)', 'type' => 'text', 'required' => false, 'rationale' => 'Only ask if your sales team actually calls — otherwise it\'s friction without payoff.'],
['tag' => 'JOB_TITLE', 'label' => 'Role / title', 'type' => 'text', 'required' => false, 'rationale' => 'Useful for ICP scoring. Keep optional.'],
],
'avoid_fields' => ['ADDRESS', 'BIRTHDAY', 'GENDER'],
'max_visible_fields' => 5,
],
[
'id' => 'b2b_demo',
'label' => 'B2B demo / discovery call',
'summary' => 'Higher-intent signup. Buyers expect to fill more fields. 4-5 fields is normal here.',
'recommended_fields' => [
['tag' => 'EMAIL', 'label' => 'Work email', 'type' => 'email', 'required' => true, 'rationale' => 'Work email gates self-qualification. Add a soft warning for free-provider domains.'],
['tag' => 'FIRST_NAME', 'label' => 'First name', 'type' => 'text', 'required' => true, 'rationale' => 'Required for sales follow-up.'],
['tag' => 'COMPANY', 'label' => 'Company', 'type' => 'text', 'required' => true, 'rationale' => 'Required — drives ICP fit + lead routing.'],
['tag' => 'COMPANY_SIZE', 'label' => 'Company size', 'type' => 'select', 'required' => true, 'rationale' => 'Select with 5-7 brackets (1-10 / 11-50 / 51-200 / 201-1000 / 1001+). Drives sales tier routing.'],
],
'optional_fields' => [
['tag' => 'JOB_TITLE', 'label' => 'Role / title', 'type' => 'text', 'required' => false, 'rationale' => 'Useful for ICP. Keep optional — buyers don\'t all want to share.'],
['tag' => 'USE_CASE', 'label' => 'What are you evaluating us for?', 'type' => 'textarea', 'required' => false, 'rationale' => 'Optional textarea — high-intent buyers fill it; low-intent skip. Free signal.'],
],
'avoid_fields' => ['PHONE', 'ADDRESS', 'BIRTHDAY'],
'max_visible_fields' => 6,
],
[
'id' => 'event_rsvp',
'label' => 'Event RSVP (one-time event)',
'summary' => 'Single-event signup. Collect what the event actually needs (attendance count, dietary, etc.). After the event, archive or convert to newsletter via tag.',
'recommended_fields' => [
['tag' => 'EMAIL', 'label' => 'Email', 'type' => 'email', 'required' => true, 'rationale' => 'Required. Use for the calendar invite + reminders.'],
['tag' => 'FIRST_NAME', 'label' => 'Full name', 'type' => 'text', 'required' => true, 'rationale' => 'Required for the attendee badge / calendar invite.'],
['tag' => 'ATTENDEES', 'label' => 'Number of attendees', 'type' => 'number', 'required' => true, 'rationale' => 'Required for venue capacity planning.'],
],
'optional_fields' => [
['tag' => 'DIETARY', 'label' => 'Dietary requirements', 'type' => 'text', 'required' => false, 'rationale' => 'Only if your event includes food. Keep optional.'],
['tag' => 'COMPANY', 'label' => 'Company (optional)', 'type' => 'text', 'required' => false, 'rationale' => 'Useful for B2B events; skip for consumer events.'],
],
'avoid_fields' => ['PHONE', 'BIRTHDAY', 'ADDRESS'],
'max_visible_fields' => 5,
],
[
'id' => 'webinar',
'label' => 'Webinar registration',
'summary' => 'Pre-event signup with reminder + replay-link follow-up. Slightly more fields than a newsletter — buyers want context but expect a quick form.',
'recommended_fields' => [
['tag' => 'EMAIL', 'label' => 'Work email', 'type' => 'email', 'required' => true, 'rationale' => 'Required — replay link + reminder go here.'],
['tag' => 'FIRST_NAME', 'label' => 'First name', 'type' => 'text', 'required' => true, 'rationale' => 'Required for personalised reminder.'],
],
'optional_fields' => [
['tag' => 'COMPANY', 'label' => 'Company (optional)', 'type' => 'text', 'required' => false, 'rationale' => 'Useful for B2B webinars; skip for consumer.'],
['tag' => 'JOB_TITLE', 'label' => 'Role / title (optional)', 'type' => 'text', 'required' => false, 'rationale' => 'Helps the host tailor the live Q&A. Optional.'],
],
'avoid_fields' => ['PHONE', 'ADDRESS', 'BIRTHDAY'],
'max_visible_fields' => 4,
],
[
'id' => 'community',
'label' => 'Community / Slack invite',
'summary' => 'Signup for a chat community. Identity matters more than commerce — first name + email is enough.',
'recommended_fields' => [
['tag' => 'EMAIL', 'label' => 'Email', 'type' => 'email', 'required' => true, 'rationale' => 'Required. Used for invite delivery.'],
['tag' => 'FIRST_NAME', 'label' => 'First name', 'type' => 'text', 'required' => true, 'rationale' => 'Required — community profiles need a name.'],
],
'optional_fields' => [
['tag' => 'INTERESTS', 'label' => 'What brings you here?', 'type' => 'textarea', 'required' => false, 'rationale' => 'Optional textarea — moderators use it to route the invite to the right channel.'],
],
'avoid_fields' => ['PHONE', 'COMPANY', 'ADDRESS', 'BIRTHDAY', 'JOB_TITLE'],
'max_visible_fields' => 3,
],
[
'id' => 'general',
'label' => 'General signup (default)',
'summary' => 'Catch-all for forms whose intent isn\'t yet clear. Mirrors the newsletter preset but flags the unclear intent.',
'recommended_fields' => [
['tag' => 'EMAIL', 'label' => 'Email', 'type' => 'email', 'required' => true, 'rationale' => 'Required for any signup.'],
['tag' => 'FIRST_NAME', 'label' => 'First name', 'type' => 'text', 'required' => false, 'rationale' => 'Optional. Only collect if you\'ll use it.'],
],
'optional_fields' => [],
'avoid_fields' => ['PHONE', 'ADDRESS', 'BIRTHDAY'],
'max_visible_fields' => 3,
],
];
}
/**
* @return array{
* id:string,label:string,summary:string,
* recommended_fields:list<array<string,mixed>>,
* optional_fields:list<array<string,mixed>>,
* avoid_fields:list<string>,
* max_visible_fields:int
* }|null
*/
public static function preset(string $id): ?array
{
foreach (self::presets() as $preset) {
if ($preset['id'] === $id) {
return $preset;
}
}
return null;
}
/**
* @return list<string>
*/
public static function presetIds(): array
{
return array_map(fn (array $p) => $p['id'], self::presets());
}
}