diff --git a/README.md b/README.md index 96224df..9920f47 100644 --- a/README.md +++ b/README.md @@ -241,14 +241,14 @@ Each slot type can describe itself: which fields its `data` takes, how to label use Odden\MailBuilder\Enums\SlotType; use Odden\MailBuilder\Schema\SlotSchemaRegistry; -$schema = SlotSchemaRegistry::for(SlotType::Hero); // null for a type that is not described yet +$schema = SlotSchemaRegistry::for(SlotType::Hero); // every slot type has a schema $schema->defaults(); // the data of a new hero slot $schema->basicFields(); // the fields of the basic form -return response()->json(SlotSchemaRegistry::toArray()); // everything an editor needs: a "version", the described slots, and a "labels" map that names every slot type, also those not described yet +return response()->json(SlotSchemaRegistry::toArray()); // everything an editor needs: a "version", the described slots, and and a "labels" map that names every slot type ``` -A field has a `key`, a `type` (`text`, `textarea`, `rich_text`, `url`, `image`, `color`, `select`, `toggle`, `number`, `items`), a `label`, `required`, a `default`, and, where they apply, `placeholder`, `help`, `options` and the item fields of a list. Fields marked `advanced` are read by the slot's view but kept out of the basic form: an editor shows them as style options. +A field has a `key`, a `type` (`text`, `textarea`, `rich_text`, `url`, `image`, `color`, `select`, `toggle`, `number`, `tags`, `items`), a `label`, `required`, a `default`, and, where they apply, `placeholder`, `help`, `options` and the item fields of a list. Fields marked `advanced` are read by the slot's view but kept out of the basic form: an editor shows them as style options. Eight types are described so far: header, hero, body text, button, image banner, features, divider and footer. The rest are rendered by their views and edited with their Filament blocks as before, and are described one at a time. Tests check that every described field is read by its slot's view, so the description cannot drift from what is rendered. @@ -256,7 +256,7 @@ Eight types are described so far: header, hero, body text, button, image banner, An editor for the slots, built as a web component with no framework: ``. It shows the email as it will be sent, with a palette of blocks, a structure list, and a settings panel for the selected block. Drag a block from the palette onto the email, or drag a block to move it. Every action also has a button and a keyboard shortcut, and changes are announced to screen readers. Undo and redo (Ctrl or Cmd + Z, Shift + Z) cover everything. -The editor edits the slot document as data and never touches HTML. The server renders the preview with the same compiler that builds the email, so what you see is what is sent, and the preview cannot be broken by editing. Only the first eight slot types are editable so far (see the slot schema above). A slot of another type in the document is kept as it is: you can move, duplicate and delete it, and its preview is rendered. +The editor edits the slot document as data and never touches HTML. The server renders the preview with the same compiler that builds the email, so what you see is what is sent, and the preview cannot be broken by editing. Every slot type is editable (see the slot schema above). A slot of a type the editor does not know, such as one from a newer version, is kept as it is: you can move, duplicate and delete it, and its preview is rendered. **1. Turn on the routes.** The editor needs two routes, a slot schema and a preview. They are off by default, because anyone who can reach them can render email HTML. Switch them on in `config/mail-builder.php` and set your own middleware: @@ -325,7 +325,7 @@ The field's theme wins over a theme sent by the browser. - **No routes and no extra setup for the preview.** The field renders the preview itself, through Livewire, so it runs under the panel's own authentication and authorization. (The routes in section 8 are for use outside Filament.) - **Publish the assets** with Filament's own command, the first time and after an update: `php artisan filament:assets`. -- **State from either shape.** A column holding Filament's keyed Builder items or a plain list both load, and the field saves a plain list. Slots of a type that is not editable yet are kept as they are. +- **State from either shape.** A column holding Filament's keyed Builder items or a plain list both load, and the field saves a plain list. Slots of a type the field does not know are kept as they are. - **It follows the form.** When the form changes the state itself, for example an action that applies a preset with `$set('slots', ...)`, the editor shows the new content. - A disabled field is shown but cannot be edited. diff --git a/resources/dist/mail-editor.js b/resources/dist/mail-editor.js index a174b39..6070298 100644 --- a/resources/dist/mail-editor.js +++ b/resources/dist/mail-editor.js @@ -387,6 +387,13 @@ function renderField(options) { return h('div', { class: 'ome-field' }, label, control, field.help ? h('p', { class: 'ome-help', id: helpId }, field.help) : null); } +/** The lines of a textarea as a list of strings: blank lines are dropped, an empty text is no list at all. */ +function parseLines(text) { + const lines = text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== ''); + + return lines.length > 0 ? lines : ''; +} + function describedBy(helpId) { return helpId ? { 'aria-describedby': helpId } : {}; } @@ -406,6 +413,17 @@ function renderControl({ field, value, path, onChange }, id, helpId) { oninput: (event) => onChange(path, event.target.value), }); + case 'tags': + // A list of short strings is edited as one per line: a cell may contain a comma, but never a line break. + return h('textarea', { + ...shared, + class: 'ome-input ome-textarea', + rows: Math.max(3, Array.isArray(value) ? value.length + 1 : 3), + placeholder: field.placeholder ?? false, + value: Array.isArray(value) ? value.join('\n') : '', + oninput: (event) => onChange(path, parseLines(event.target.value)), + }); + case 'select': return renderSelect({ field, value, path, onChange }, shared); diff --git a/resources/js/editor/fields.js b/resources/js/editor/fields.js index 5459eb4..79fa134 100644 --- a/resources/js/editor/fields.js +++ b/resources/js/editor/fields.js @@ -32,6 +32,13 @@ export function renderField(options) { return h('div', { class: 'ome-field' }, label, control, field.help ? h('p', { class: 'ome-help', id: helpId }, field.help) : null); } +/** The lines of a textarea as a list of strings: blank lines are dropped, an empty text is no list at all. */ +export function parseLines(text) { + const lines = text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== ''); + + return lines.length > 0 ? lines : ''; +} + function describedBy(helpId) { return helpId ? { 'aria-describedby': helpId } : {}; } @@ -51,6 +58,17 @@ function renderControl({ field, value, path, onChange }, id, helpId) { oninput: (event) => onChange(path, event.target.value), }); + case 'tags': + // A list of short strings is edited as one per line: a cell may contain a comma, but never a line break. + return h('textarea', { + ...shared, + class: 'ome-input ome-textarea', + rows: Math.max(3, Array.isArray(value) ? value.length + 1 : 3), + placeholder: field.placeholder ?? false, + value: Array.isArray(value) ? value.join('\n') : '', + oninput: (event) => onChange(path, parseLines(event.target.value)), + }); + case 'select': return renderSelect({ field, value, path, onChange }, shared); diff --git a/src/Filament/Components/EmailSlotBuilder.php b/src/Filament/Components/EmailSlotBuilder.php index 1e137a8..8763523 100644 --- a/src/Filament/Components/EmailSlotBuilder.php +++ b/src/Filament/Components/EmailSlotBuilder.php @@ -7,13 +7,8 @@ use Filament\Actions\Action; use Filament\Forms\Components\Builder; use Filament\Forms\Components\Builder\Block; -use Filament\Forms\Components\ColorPicker; -use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; -use Filament\Forms\Components\TagsInput; -use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; -use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; use Filament\Schemas\Components\Fieldset; use Filament\Support\Icons\Heroicon; @@ -104,44 +99,7 @@ public static function getButtonBlock(): Block */ public static function getTwoColumnBlock(): Block { - return Block::make(SlotType::TwoColumn->value) - ->label(SlotType::TwoColumn->label()) - ->icon(Heroicon::ViewColumns) - ->schema([ - TextInput::make('left_title') - ->label('Left Column Title') - ->placeholder('Feature or Topic A') - ->required(), - TextInput::make('right_title') - ->label('Right Column Title') - ->placeholder('Feature or Topic B') - ->required(), - Textarea::make('left_body') - ->label('Left Column Content') - ->rows(3) - ->required(), - Textarea::make('right_body') - ->label('Right Column Content') - ->rows(3) - ->required(), - TextInput::make('left_button_text') - ->label('Left Link Label') - ->placeholder('Learn More'), - TextInput::make('right_button_text') - ->label('Right Link Label') - ->placeholder('Learn More'), - TextInput::make('left_button_url') - ->label('Left Destination URL') - ->placeholder('https://...'), - TextInput::make('right_button_url') - ->label('Right Destination URL') - ->placeholder('https://...'), - Toggle::make('reverse_stack_on_mobile') - ->label('Reverse Column Stack on Mobile') - ->default(false), - self::getVisibilityFieldset()->columnSpanFull(), - ]) - ->columns(2); + return SchemaBlockFactory::make(SlotType::TwoColumn); } /** @@ -157,34 +115,7 @@ public static function getFeaturesBlock(): Block */ public static function getTestimonialBlock(): Block { - return Block::make(SlotType::Testimonial->value) - ->label(SlotType::Testimonial->label()) - ->icon(Heroicon::ChatBubbleLeftRight) - ->schema([ - Textarea::make('quote') - ->label('Customer Quote') - ->rows(3) - ->required() - ->columnSpanFull(), - TextInput::make('author') - ->label('Author Name') - ->required(), - TextInput::make('role') - ->label('Job Role / Title') - ->placeholder('e.g. VP of Growth'), - TextInput::make('company') - ->label('Company Name') - ->placeholder('e.g. Acme Corp'), - Select::make('rating') - ->label('Star Rating') - ->options([ - 5 => '★★★★★ (5 Stars)', - 4 => '★★★★☆ (4 Stars)', - 3 => '★★★☆☆ (3 Stars)', - ]) - ->default(5), - ]) - ->columns(2); + return SchemaBlockFactory::make(SlotType::Testimonial); } /** @@ -192,32 +123,7 @@ public static function getTestimonialBlock(): Block */ public static function getStatBoxBlock(): Block { - return Block::make(SlotType::StatBox->value) - ->label(SlotType::StatBox->label()) - ->icon(Heroicon::ChartBar) - ->schema([ - TextInput::make('heading') - ->label('Section Heading (Optional)') - ->placeholder('e.g. Platform Highlights'), - Repeater::make('stats') - ->label('Key Metrics') - ->schema([ - TextInput::make('value') - ->label('Metric / Number') - ->placeholder('e.g. 99.9% or +45%') - ->required(), - TextInput::make('label') - ->label('Label') - ->placeholder('e.g. Uptime SLA') - ->required(), - TextInput::make('change') - ->label('Badge / Pill (Optional)') - ->placeholder('e.g. +14% YoY'), - ]) - ->columns(3) - ->defaultItems(2) - ->columnSpanFull(), - ]); + return SchemaBlockFactory::make(SlotType::StatBox); } /** @@ -233,34 +139,7 @@ public static function getDividerBlock(): Block */ public static function getSocialLinksBlock(): Block { - return Block::make(SlotType::SocialLinks->value) - ->label(SlotType::SocialLinks->label()) - ->icon(Heroicon::Share) - ->schema([ - Repeater::make('links') - ->label('Social Profiles') - ->schema([ - Select::make('network') - ->label('Network') - ->options([ - 'Twitter' => 'Twitter / X', - 'LinkedIn' => 'LinkedIn', - 'GitHub' => 'GitHub', - 'YouTube' => 'YouTube', - 'Facebook' => 'Facebook', - 'Website' => 'Website', - ]) - ->default('Twitter') - ->required(), - TextInput::make('url') - ->label('Profile URL') - ->url() - ->required(), - ]) - ->columns(2) - ->defaultItems(3) - ->columnSpanFull(), - ]); + return SchemaBlockFactory::make(SlotType::SocialLinks); } /** @@ -276,16 +155,7 @@ public static function getFooterBlock(): Block */ public static function getHtmlBlock(): Block { - return Block::make(SlotType::Html->value) - ->label(SlotType::Html->label()) - ->icon(Heroicon::CodeBracket) - ->schema([ - Textarea::make('html') - ->label('Raw HTML Code') - ->rows(8) - ->required() - ->columnSpanFull(), - ]); + return SchemaBlockFactory::make(SlotType::Html); } /** @@ -301,34 +171,7 @@ public static function getImageBannerBlock(): Block */ public static function getVideoCardBlock(): Block { - return Block::make(SlotType::VideoCard->value) - ->label(SlotType::VideoCard->label()) - ->icon(Heroicon::PlayCircle) - ->schema([ - TextInput::make('video_url') - ->label('Video Destination URL (YouTube, Vimeo, Loom)') - ->placeholder('https://www.youtube.com/watch?v=...') - ->required() - ->columnSpanFull(), - TextInput::make('thumbnail_url') - ->label('Custom Thumbnail Image URL') - ->placeholder('https://images.unsplash.com/...'), - TextInput::make('badge') - ->label('Badge Text') - ->default('VIDEO DEMO'), - TextInput::make('title') - ->label('Video Title') - ->default('Watch Product Tour & Feature Walkthrough') - ->required() - ->columnSpanFull(), - Textarea::make('subtitle') - ->label('Video Description / Duration') - ->rows(2) - ->placeholder('e.g. 3 min overview of platform features') - ->columnSpanFull(), - self::getVisibilityFieldset()->columnSpanFull(), - ]) - ->columns(2); + return SchemaBlockFactory::make(SlotType::VideoCard); } /** @@ -336,48 +179,7 @@ public static function getVideoCardBlock(): Block */ public static function getPricingGridBlock(): Block { - return Block::make(SlotType::PricingGrid->value) - ->label(SlotType::PricingGrid->label()) - ->icon(Heroicon::CurrencyDollar) - ->schema([ - TextInput::make('heading') - ->label('Section Heading') - ->placeholder('Simple, transparent pricing'), - TextInput::make('subtitle') - ->label('Subtitle') - ->placeholder('Start free, upgrade as you grow'), - Repeater::make('tiers') - ->label('Pricing Tiers') - ->schema([ - TextInput::make('name') - ->label('Plan Name') - ->placeholder('Starter') - ->required(), - TextInput::make('price') - ->label('Price') - ->placeholder('$49') - ->required(), - TextInput::make('frequency') - ->label('Billing Frequency') - ->placeholder('/ month') - ->default('/ month'), - TextInput::make('button_text') - ->label('CTA Button Text') - ->default('Get Started') - ->required(), - TextInput::make('button_url') - ->label('CTA Destination URL') - ->default('https://example.com/signup') - ->required(), - Toggle::make('is_popular') - ->label('Highlight as Most Popular') - ->default(false), - ]) - ->columns(3) - ->defaultItems(2) - ->columnSpanFull(), - self::getVisibilityFieldset()->columnSpanFull(), - ]); + return SchemaBlockFactory::make(SlotType::PricingGrid); } /** @@ -385,39 +187,7 @@ public static function getPricingGridBlock(): Block */ public static function getRatingBarBlock(): Block { - return Block::make(SlotType::RatingBar->value) - ->label(SlotType::RatingBar->label()) - ->icon(Heroicon::Star) - ->schema([ - TextInput::make('question') - ->label('Survey Question Prompt') - ->default('How likely are you to recommend us to a colleague?') - ->required() - ->columnSpanFull(), - Select::make('scale_type') - ->label('Rating Scale') - ->options([ - '10' => '0 to 10 Scale (Industry Standard NPS)', - '5' => '1 to 5 Stars / CSAT Scale', - ]) - ->default('10') - ->required(), - TextInput::make('base_url') - ->label('Destination Rating Endpoint / Webhook') - ->default('/feedback/nps/{{nps_token}}') - ->helperText('Each button appends /{score} to this URL.') - ->required(), - TextInput::make('low_label') - ->label('Lowest Score Meaning') - ->default('Not at all likely') - ->required(), - TextInput::make('high_label') - ->label('Highest Score Meaning') - ->default('Extremely likely') - ->required(), - self::getVisibilityFieldset()->columnSpanFull(), - ]) - ->columns(2); + return SchemaBlockFactory::make(SlotType::RatingBar); } /** @@ -425,38 +195,7 @@ public static function getRatingBarBlock(): Block */ public static function getCountdownTimerBlock(): Block { - return Block::make(SlotType::CountdownTimer->value) - ->label(SlotType::CountdownTimer->label()) - ->icon(Heroicon::Clock) - ->schema([ - TextInput::make('badge') - ->label('Urgency Badge') - ->default('⚡ LIMITED TIME OFFER'), - TextInput::make('deadline_text') - ->label('Deadline Description') - ->default('Offer expires Sunday at Midnight') - ->required(), - TextInput::make('heading') - ->label('Headline') - ->default('Unlock 20% Off Your Annual Plan') - ->required() - ->columnSpanFull(), - Textarea::make('subtext') - ->label('Promotional Subtext') - ->rows(2) - ->default('Lock in early-bird pricing before this promotional tier expires.') - ->columnSpanFull(), - TextInput::make('button_text') - ->label('Action Button Label') - ->default('Claim Your Discount Now') - ->required(), - TextInput::make('button_url') - ->label('Action Destination URL') - ->default('https://example.com/checkout?promo=FLASH20') - ->required(), - self::getVisibilityFieldset()->columnSpanFull(), - ]) - ->columns(2); + return SchemaBlockFactory::make(SlotType::CountdownTimer); } /** @@ -464,36 +203,7 @@ public static function getCountdownTimerBlock(): Block */ public static function getAccordionBlock(): Block { - return Block::make(SlotType::Accordion->value) - ->label(SlotType::Accordion->label()) - ->icon(Heroicon::QuestionMarkCircle) - ->schema([ - TextInput::make('heading') - ->label('Section Heading') - ->default('Frequently Asked Questions') - ->columnSpanFull(), - TextInput::make('subtitle') - ->label('Section Subtitle') - ->placeholder('Quick answers to common questions') - ->columnSpanFull(), - Repeater::make('items') - ->label('Questions & Answers') - ->schema([ - TextInput::make('question') - ->label('Question') - ->placeholder('e.g. Can I upgrade my plan later?') - ->required(), - Textarea::make('answer') - ->label('Answer') - ->rows(2) - ->placeholder('e.g. Yes, you can change your subscription anytime in account billing.') - ->required(), - ]) - ->columns(1) - ->defaultItems(2) - ->columnSpanFull(), - self::getVisibilityFieldset()->columnSpanFull(), - ]); + return SchemaBlockFactory::make(SlotType::Accordion); } /** @@ -501,47 +211,7 @@ public static function getAccordionBlock(): Block */ public static function getDynamicFeedBlock(): Block { - return Block::make(SlotType::DynamicFeed->value) - ->label(SlotType::DynamicFeed->label()) - ->icon(Heroicon::Sparkles) - ->schema([ - TextInput::make('heading') - ->label('Section Heading') - ->default('Recommended For You') - ->columnSpanFull(), - TextInput::make('subtitle') - ->label('Subtitle') - ->placeholder('Curated recommendations based on your preferences') - ->columnSpanFull(), - Repeater::make('items') - ->label('Dynamic Products / Content Items') - ->schema([ - TextInput::make('title') - ->label('Item Title') - ->required(), - TextInput::make('price') - ->label('Price / Badge') - ->placeholder('e.g. $199 / mo'), - TextInput::make('image_url') - ->label('Thumbnail Image URL') - ->url(), - TextInput::make('button_url') - ->label('Target URL') - ->url() - ->required(), - TextInput::make('button_text') - ->label('Button CTA') - ->default('View Details'), - Textarea::make('description') - ->label('Description') - ->rows(2) - ->columnSpanFull(), - ]) - ->columns(2) - ->defaultItems(2) - ->columnSpanFull(), - self::getVisibilityFieldset()->columnSpanFull(), - ]); + return SchemaBlockFactory::make(SlotType::DynamicFeed); } /** @@ -549,68 +219,7 @@ public static function getDynamicFeedBlock(): Block */ public static function getOrderReceiptBlock(): Block { - return Block::make(SlotType::OrderReceipt->value) - ->label(SlotType::OrderReceipt->label()) - ->icon(Heroicon::ShoppingBag) - ->schema([ - TextInput::make('order_number') - ->label('Order / Reference #') - ->default('1001') - ->required(), - TextInput::make('order_date') - ->label('Order Date') - ->placeholder('e.g. October 24, 2026'), - Select::make('status') - ->label('Order Status') - ->options([ - 'Confirmed' => 'Confirmed', - 'Processing' => 'Processing', - 'Shipped' => 'Shipped', - 'Delivered' => 'Delivered', - 'Refunded' => 'Refunded', - ]) - ->default('Confirmed'), - TextInput::make('currency') - ->label('Currency Symbol') - ->default('$') - ->maxLength(5), - Repeater::make('items') - ->label('Purchased Items') - ->schema([ - TextInput::make('name') - ->label('Product / Service Name') - ->required(), - TextInput::make('quantity') - ->label('Qty') - ->numeric() - ->default(1) - ->required(), - TextInput::make('price') - ->label('Unit Price') - ->default('0.00') - ->required(), - TextInput::make('variant') - ->label('Variant / SKU / Options') - ->placeholder('e.g. Size L / Dark Gray'), - ]) - ->columns(4) - ->defaultItems(1) - ->columnSpanFull(), - TextInput::make('subtotal') - ->label('Subtotal Amount') - ->default('0.00'), - TextInput::make('shipping') - ->label('Shipping Fee') - ->default('0.00'), - TextInput::make('tax') - ->label('Tax / VAT') - ->default('0.00'), - TextInput::make('total') - ->label('Grand Total') - ->default('0.00') - ->required(), - self::getVisibilityFieldset()->columnSpanFull(), - ]); + return SchemaBlockFactory::make(SlotType::OrderReceipt); } /** @@ -618,48 +227,7 @@ public static function getOrderReceiptBlock(): Block */ public static function getRssFeedBlock(): Block { - return Block::make(SlotType::RssFeed->value) - ->label(SlotType::RssFeed->label()) - ->icon(Heroicon::Rss) - ->schema([ - TextInput::make('heading') - ->label('Feed Title') - ->default('Latest Articles & Updates') - ->columnSpanFull(), - TextInput::make('subtitle') - ->label('Subtitle') - ->placeholder('Latest news from our RSS syndication feed') - ->columnSpanFull(), - TextInput::make('feed_url') - ->label('Source RSS / Atom URL (Optional)') - ->url() - ->columnSpanFull(), - Repeater::make('items') - ->label('Feed Articles') - ->schema([ - TextInput::make('title') - ->label('Article Title') - ->required(), - TextInput::make('published_at') - ->label('Published Date') - ->placeholder('e.g. Oct 15, 2026'), - TextInput::make('author') - ->label('Author / Source') - ->placeholder('e.g. Editorial Desk'), - TextInput::make('url') - ->label('Article Link') - ->url() - ->required(), - Textarea::make('summary') - ->label('Article Summary') - ->rows(2) - ->columnSpanFull(), - ]) - ->columns(2) - ->defaultItems(2) - ->columnSpanFull(), - self::getVisibilityFieldset()->columnSpanFull(), - ]); + return SchemaBlockFactory::make(SlotType::RssFeed); } /** @@ -667,45 +235,7 @@ public static function getRssFeedBlock(): Block */ public static function getThreeColumnBlock(): Block { - return Block::make(SlotType::ThreeColumn->value) - ->label(SlotType::ThreeColumn->label()) - ->icon(Heroicon::ViewColumns) - ->schema([ - TextInput::make('heading') - ->label('Section Heading') - ->placeholder('e.g. Featured Highlights') - ->columnSpanFull(), - TextInput::make('subtitle') - ->label('Subtitle') - ->placeholder('e.g. Explore our newest feature releases') - ->columnSpanFull(), - Repeater::make('columns') - ->label('Three Columns') - ->schema([ - TextInput::make('title') - ->label('Column Title') - ->required(), - TextInput::make('image_url') - ->label('Image URL') - ->url(), - Textarea::make('description') - ->label('Description') - ->rows(2) - ->columnSpanFull(), - TextInput::make('button_text') - ->label('CTA Button Text') - ->default('Learn More'), - TextInput::make('button_url') - ->label('Target Link') - ->url() - ->default('#'), - ]) - ->columns(2) - ->defaultItems(3) - ->maxItems(3) - ->columnSpanFull(), - self::getVisibilityFieldset()->columnSpanFull(), - ]); + return SchemaBlockFactory::make(SlotType::ThreeColumn); } /** @@ -713,32 +243,7 @@ public static function getThreeColumnBlock(): Block */ public static function getFourColumnBlock(): Block { - return Block::make(SlotType::FourColumn->value) - ->label(SlotType::FourColumn->label()) - ->icon(Heroicon::Squares2x2) - ->schema([ - TextInput::make('heading') - ->label('Header Label') - ->default('Trusted by Industry Leaders') - ->columnSpanFull(), - Repeater::make('items') - ->label('Brand / Partner Logos') - ->schema([ - TextInput::make('label') - ->label('Brand / Partner Name') - ->required(), - TextInput::make('image_url') - ->label('Logo Image URL (PNG/SVG/WebP)') - ->url(), - TextInput::make('url') - ->label('Website Link (Optional)') - ->url(), - ]) - ->columns(3) - ->defaultItems(4) - ->columnSpanFull(), - self::getVisibilityFieldset()->columnSpanFull(), - ]); + return SchemaBlockFactory::make(SlotType::FourColumn); } /** @@ -746,38 +251,7 @@ public static function getFourColumnBlock(): Block */ public static function getAsymmetricColumnsBlock(): Block { - return Block::make(SlotType::AsymmetricColumns->value) - ->label(SlotType::AsymmetricColumns->label()) - ->icon(Heroicon::RectangleGroup) - ->schema([ - Select::make('ratio') - ->label('Layout Distribution') - ->options([ - '30_70' => '1/3 Left Sidebar + 2/3 Main Story', - '70_30' => '2/3 Main Story + 1/3 Right Sidebar', - ]) - ->default('30_70') - ->required() - ->columnSpanFull(), - Fieldset::make('Left Column Content') - ->schema([ - TextInput::make('left_title')->label('Left Title'), - TextInput::make('left_image')->label('Left Image URL')->url(), - Textarea::make('left_body')->label('Left Copy')->rows(3)->columnSpanFull(), - ColorPicker::make('left_bg_color')->label('Background Color')->default('#F8FAFC'), - ]) - ->columns(2), - Fieldset::make('Right Column Content') - ->schema([ - TextInput::make('right_title')->label('Right Title'), - TextInput::make('right_button_text')->label('CTA Button Text')->default('Read Story'), - TextInput::make('right_button_url')->label('CTA Button URL')->url()->default('#'), - ColorPicker::make('right_bg_color')->label('Background Color')->default('#FFFFFF'), - Textarea::make('right_body')->label('Right Copy')->rows(3)->columnSpanFull(), - ]) - ->columns(2), - self::getVisibilityFieldset()->columnSpanFull(), - ]); + return SchemaBlockFactory::make(SlotType::AsymmetricColumns); } /** @@ -785,77 +259,7 @@ public static function getAsymmetricColumnsBlock(): Block */ public static function getProductCatalogBlock(): Block { - return Block::make(SlotType::ProductCatalog->value) - ->label(SlotType::ProductCatalog->label()) - ->icon(Heroicon::ShoppingBag) - ->schema([ - TextInput::make('section_title') - ->label('Section Header') - ->placeholder('Recommended For You / Items in Your Cart') - ->columnSpanFull(), - TextInput::make('section_subtitle') - ->label('Section Subtitle') - ->placeholder('Hand-picked recommendations based on your preferences') - ->columnSpanFull(), - Select::make('columns') - ->label('Columns Count') - ->options([ - 1 => '1 Column (Large Featured Cards)', - 2 => '2 Columns (Standard Grid)', - 3 => '3 Columns (Compact Catalog)', - ]) - ->default(2) - ->required(), - Toggle::make('show_badges') - ->label('Show Discount & Stock Badges') - ->default(true), - ColorPicker::make('card_background') - ->label('Card Background Color') - ->default('#ffffff'), - ColorPicker::make('card_border') - ->label('Card Border Color') - ->default('#e2e8f0'), - Repeater::make('products') - ->label('Products / Cart Line Items') - ->schema([ - TextInput::make('title') - ->label('Product Title') - ->required(), - TextInput::make('price') - ->label('Current Price') - ->placeholder('$49.00') - ->required(), - TextInput::make('compare_at_price') - ->label('Compare-at / Original Price (Strikethrough)') - ->placeholder('$79.00'), - TextInput::make('savings_badge') - ->label('Discount Badge (e.g. Save 30%)') - ->placeholder('Save 30%'), - TextInput::make('stock_badge') - ->label('Stock Badge (e.g. In Stock / Low Stock)') - ->placeholder('In Stock'), - TextInput::make('image_url') - ->label('Product Image URL') - ->url() - ->required(), - TextInput::make('button_text') - ->label('Button CTA Label') - ->default('Buy Now') - ->required(), - TextInput::make('button_url') - ->label('Checkout / Product URL') - ->url() - ->required(), - Textarea::make('description') - ->label('Short Description') - ->rows(2) - ->columnSpanFull(), - ]) - ->columns(2) - ->defaultItems(2) - ->columnSpanFull(), - self::getVisibilityFieldset()->columnSpanFull(), - ]); + return SchemaBlockFactory::make(SlotType::ProductCatalog); } /** @@ -863,48 +267,7 @@ public static function getProductCatalogBlock(): Block */ public static function getCouponCodeBlock(): Block { - return Block::make(SlotType::CouponCode->value) - ->label(SlotType::CouponCode->label()) - ->icon(Heroicon::Ticket) - ->schema([ - TextInput::make('headline') - ->label('Voucher Headline') - ->default('Special Discount Voucher') - ->required(), - TextInput::make('discount_badge') - ->label('Discount Badge (e.g. 20% OFF)') - ->default('20% OFF'), - TextInput::make('code') - ->label('Promo / Coupon Code') - ->placeholder('SAVE20') - ->required(), - TextInput::make('expires_at') - ->label('Expiration Notice') - ->placeholder('e.g. October 31, 2026 or In 3 Days'), - TextInput::make('button_text') - ->label('Redeem CTA Button Text') - ->default('Claim Offer'), - TextInput::make('button_url') - ->label('Redeem CTA URL') - ->url() - ->default('#'), - Textarea::make('description') - ->label('Terms or Description') - ->default('Apply this promo code at checkout to claim your savings.') - ->rows(2) - ->columnSpanFull(), - ColorPicker::make('card_background') - ->label('Voucher Background Color') - ->default('#f8fafc'), - ColorPicker::make('border_color') - ->label('Dashed Border Color') - ->default('#94a3b8'), - ColorPicker::make('code_background') - ->label('Code Box Background Color') - ->default('#ffffff'), - self::getVisibilityFieldset()->columnSpanFull(), - ]) - ->columns(2); + return SchemaBlockFactory::make(SlotType::CouponCode); } /** @@ -959,36 +322,7 @@ public static function applyPresetAction(string $targetSlotField = 'slots'): Act */ public static function getAppBadgesBlock(): Block { - return Block::make(SlotType::AppBadges->value) - ->label(SlotType::AppBadges->label()) - ->icon(Heroicon::DevicePhoneMobile) - ->schema([ - TextInput::make('heading') - ->label('Section Heading') - ->placeholder('Download Our Mobile App') - ->columnSpanFull(), - TextInput::make('subtitle') - ->label('Subtitle') - ->placeholder('Manage your account on the go') - ->columnSpanFull(), - TextInput::make('app_store_url') - ->label('Apple App Store Link URL') - ->placeholder('https://apps.apple.com/app/...') - ->default('#'), - TextInput::make('google_play_url') - ->label('Google Play Store Link URL') - ->placeholder('https://play.google.com/store/apps/...') - ->default('#'), - Select::make('align') - ->label('Alignment') - ->options([ - 'center' => 'Center Aligned', - 'left' => 'Left Aligned', - ]) - ->default('center'), - self::getVisibilityFieldset()->columnSpanFull(), - ]) - ->columns(2); + return SchemaBlockFactory::make(SlotType::AppBadges); } /** @@ -996,36 +330,7 @@ public static function getAppBadgesBlock(): Block */ public static function getDataTableBlock(): Block { - return Block::make(SlotType::DataTable->value) - ->label(SlotType::DataTable->label()) - ->icon(Heroicon::TableCells) - ->schema([ - TextInput::make('heading') - ->label('Table Heading (Optional)') - ->placeholder('Feature & Tier Comparison') - ->columnSpanFull(), - TextInput::make('subtitle') - ->label('Table Subtitle (Optional)') - ->placeholder('Compare available features across plans') - ->columnSpanFull(), - TagsInput::make('headers') - ->label('Column Header Names') - ->placeholder('Add column name (e.g. Feature, Starter, Pro)') - ->default(['Feature', 'Starter', 'Professional', 'Enterprise']) - ->columnSpanFull(), - Repeater::make('rows') - ->label('Table Rows') - ->schema([ - TagsInput::make('cells') - ->label('Row Cell Values (Matching Column Count)') - ->placeholder('Type cell value and press Enter') - ->required(), - ]) - ->collapsible() - ->defaultItems(3) - ->columnSpanFull(), - self::getVisibilityFieldset()->columnSpanFull(), - ]); + return SchemaBlockFactory::make(SlotType::DataTable); } /** @@ -1033,34 +338,7 @@ public static function getDataTableBlock(): Block */ public static function getLabeledDividerBlock(): Block { - return Block::make(SlotType::LabeledDivider->value) - ->label(SlotType::LabeledDivider->label()) - ->icon(Heroicon::Minus) - ->schema([ - TextInput::make('label') - ->label('Divider Center Label / Badge') - ->default('OR') - ->required(), - ColorPicker::make('badge_bg') - ->label('Badge Background Color') - ->default('#f1f5f9'), - ColorPicker::make('text_color') - ->label('Badge Text Color') - ->default('#64748b'), - ColorPicker::make('line_color') - ->label('Divider Line Color') - ->default('#e2e8f0'), - Select::make('padding_y') - ->label('Vertical Spacing') - ->options([ - 16 => 'Compact (16px)', - 24 => 'Standard (24px)', - 36 => 'Generous (36px)', - ]) - ->default(24), - self::getVisibilityFieldset()->columnSpanFull(), - ]) - ->columns(2); + return SchemaBlockFactory::make(SlotType::LabeledDivider); } /** diff --git a/src/Filament/Components/SchemaBlockFactory.php b/src/Filament/Components/SchemaBlockFactory.php index d876410..956745e 100644 --- a/src/Filament/Components/SchemaBlockFactory.php +++ b/src/Filament/Components/SchemaBlockFactory.php @@ -10,6 +10,7 @@ use Filament\Forms\Components\Repeater; use Filament\Forms\Components\RichEditor; use Filament\Forms\Components\Select; +use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; @@ -29,9 +30,7 @@ final class SchemaBlockFactory { public static function make(SlotType $type): Block { - $schema = SlotSchemaRegistry::for($type) ?? throw new \LogicException("No schema for slot type [{$type->value}]."); - - return self::fromSchema($schema); + return self::fromSchema(SlotSchemaRegistry::for($type)); } public static function fromSchema(SlotSchema $schema): Block @@ -68,6 +67,7 @@ private static function field(SlotField $field): Field FieldType::Toggle => Toggle::make($field->key), FieldType::Select => Select::make($field->key)->options(self::options($field)), FieldType::Number => TextInput::make($field->key)->numeric(), + FieldType::Tags => TagsInput::make($field->key), FieldType::Items => Repeater::make($field->key) ->schema(array_map(fn (SlotField $item): Field => self::field($item), $field->items)) ->columns($field->itemColumns > 0 ? $field->itemColumns : null) diff --git a/src/Schema/FieldType.php b/src/Schema/FieldType.php index 5381885..84389b9 100644 --- a/src/Schema/FieldType.php +++ b/src/Schema/FieldType.php @@ -19,6 +19,9 @@ enum FieldType: string case Toggle = 'toggle'; case Number = 'number'; + /** A list of short strings (feature bullets, table cells). Stored as a plain array of strings. */ + case Tags = 'tags'; + /** A list of items, each described by the field's own `items` fields. */ case Items = 'items'; } diff --git a/src/Schema/SlotSchemaRegistry.php b/src/Schema/SlotSchemaRegistry.php index f8ec295..7ce40a1 100644 --- a/src/Schema/SlotSchemaRegistry.php +++ b/src/Schema/SlotSchemaRegistry.php @@ -8,15 +8,15 @@ use Odden\MailBuilder\Enums\SlotType; /** - * The schemas of the slot types that have been described so far. + * The schemas of the slot types: what each type's `data` takes and how an editor shows it. * - * A type without a schema (returned as null) is not yet editable in the drag-and-drop editor: it is still rendered - * by its view and edited with its Filament block. Types are described one at a time. + * Every slot type is described, and a test fails when a type is added to the enum without one. An editor still keeps a + * block of a type it does not know (a document from a newer version): it can be moved, duplicated and deleted, not edited. */ final class SlotSchemaRegistry { /** Bumped when the shape of the exported schema changes. */ - public const VERSION = 1; + public const VERSION = 2; /** * The slot types that have a schema, in the order an editor lists them. @@ -31,8 +31,29 @@ public static function types(): array SlotType::BodyText, SlotType::Button, SlotType::ImageBanner, + SlotType::TwoColumn, + SlotType::ThreeColumn, + SlotType::FourColumn, + SlotType::AsymmetricColumns, SlotType::Features, + SlotType::Testimonial, + SlotType::StatBox, + SlotType::SocialLinks, + SlotType::VideoCard, + SlotType::PricingGrid, + SlotType::ProductCatalog, + SlotType::DynamicFeed, + SlotType::RssFeed, + SlotType::Accordion, + SlotType::OrderReceipt, + SlotType::DataTable, + SlotType::CouponCode, + SlotType::CountdownTimer, + SlotType::RatingBar, + SlotType::AppBadges, SlotType::Divider, + SlotType::LabeledDivider, + SlotType::Html, SlotType::Footer, ]; } @@ -42,7 +63,7 @@ public static function has(SlotType $type): bool return in_array($type, self::types(), true); } - public static function for(SlotType $type): ?SlotSchema + public static function for(SlotType $type): SlotSchema { return match ($type) { SlotType::Header => self::header(), @@ -50,10 +71,30 @@ public static function for(SlotType $type): ?SlotSchema SlotType::BodyText => self::bodyText(), SlotType::Button => self::button(), SlotType::ImageBanner => self::imageBanner(), + SlotType::TwoColumn => self::twoColumn(), + SlotType::ThreeColumn => self::threeColumn(), + SlotType::FourColumn => self::fourColumn(), + SlotType::AsymmetricColumns => self::asymmetricColumns(), SlotType::Features => self::features(), + SlotType::Testimonial => self::testimonial(), + SlotType::StatBox => self::statBox(), + SlotType::SocialLinks => self::socialLinks(), + SlotType::VideoCard => self::videoCard(), + SlotType::PricingGrid => self::pricingGrid(), + SlotType::ProductCatalog => self::productCatalog(), + SlotType::DynamicFeed => self::dynamicFeed(), + SlotType::RssFeed => self::rssFeed(), + SlotType::Accordion => self::accordion(), + SlotType::OrderReceipt => self::orderReceipt(), + SlotType::DataTable => self::dataTable(), + SlotType::CouponCode => self::couponCode(), + SlotType::CountdownTimer => self::countdownTimer(), + SlotType::RatingBar => self::ratingBar(), + SlotType::AppBadges => self::appBadges(), SlotType::Divider => self::divider(), + SlotType::LabeledDivider => self::labeledDivider(), + SlotType::Html => self::html(), SlotType::Footer => self::footer(), - default => null, }; } @@ -62,7 +103,7 @@ public static function for(SlotType $type): ?SlotSchema */ public static function all(): array { - return array_map(fn (SlotType $type): SlotSchema => self::for($type) ?? throw new \LogicException("No schema for [{$type->value}]."), self::types()); + return array_map(fn (SlotType $type): SlotSchema => self::for($type), self::types()); } /** @@ -229,4 +270,342 @@ private static function footer(): SlotSchema new SlotField('copyright_year', FieldType::Number, 'Copyright Year', advanced: true), ], columns: 2); } + + private static function twoColumn(): SlotSchema + { + return new SlotSchema(SlotType::TwoColumn, 'view-columns', [ + new SlotField('left_title', FieldType::Text, 'Left Column Title', required: true, placeholder: 'Feature or Topic A'), + new SlotField('right_title', FieldType::Text, 'Right Column Title', required: true, placeholder: 'Feature or Topic B'), + new SlotField('left_body', FieldType::Textarea, 'Left Column Content', required: true, rows: 3), + new SlotField('right_body', FieldType::Textarea, 'Right Column Content', required: true, rows: 3), + new SlotField('left_image', FieldType::Image, 'Left Image URL', placeholder: 'https://...'), + new SlotField('right_image', FieldType::Image, 'Right Image URL', placeholder: 'https://...'), + new SlotField('left_button_text', FieldType::Text, 'Left Link Label', placeholder: 'Learn More'), + new SlotField('right_button_text', FieldType::Text, 'Right Link Label', placeholder: 'Learn More'), + new SlotField('left_button_url', FieldType::Url, 'Left Destination URL', placeholder: 'https://...'), + new SlotField('right_button_url', FieldType::Url, 'Right Destination URL', placeholder: 'https://...'), + new SlotField('reverse_stack_on_mobile', FieldType::Toggle, 'Reverse Column Stack on Mobile', default: false), + new SlotField('bg_color', FieldType::Color, 'Background Color', advanced: true), + ], columns: 2, supportsVisibility: true); + } + + private static function threeColumn(): SlotSchema + { + return new SlotSchema(SlotType::ThreeColumn, 'view-columns', [ + new SlotField('heading', FieldType::Text, 'Section Heading', placeholder: 'e.g. Featured Highlights', fullWidth: true), + new SlotField('subtitle', FieldType::Text, 'Subtitle', placeholder: 'e.g. Explore our newest feature releases', fullWidth: true), + new SlotField('columns', FieldType::Items, 'Three Columns', fullWidth: true, defaultItems: 3, itemColumns: 2, items: [ + new SlotField('title', FieldType::Text, 'Column Title', required: true), + new SlotField('image_url', FieldType::Image, 'Image URL'), + new SlotField('description', FieldType::Textarea, 'Description', rows: 2, fullWidth: true), + new SlotField('button_text', FieldType::Text, 'CTA Button Text', default: 'Learn More'), + new SlotField('button_url', FieldType::Url, 'Target Link', default: '#'), + ]), + ], supportsVisibility: true); + } + + private static function fourColumn(): SlotSchema + { + return new SlotSchema(SlotType::FourColumn, 'squares-2x2', [ + new SlotField('heading', FieldType::Text, 'Header Label', default: 'Trusted by Industry Leaders', fullWidth: true), + new SlotField('items', FieldType::Items, 'Brand / Partner Logos', fullWidth: true, defaultItems: 4, itemColumns: 3, items: [ + new SlotField('label', FieldType::Text, 'Brand / Partner Name', required: true), + new SlotField('image_url', FieldType::Image, 'Logo Image URL (PNG/SVG/WebP)'), + new SlotField('url', FieldType::Url, 'Website Link (Optional)'), + ]), + ], supportsVisibility: true); + } + + private static function asymmetricColumns(): SlotSchema + { + return new SlotSchema(SlotType::AsymmetricColumns, 'rectangle-group', [ + new SlotField('ratio', FieldType::Select, 'Layout Distribution', required: true, default: '30_70', fullWidth: true, options: [ + ['value' => '30_70', 'label' => '1/3 Left Sidebar + 2/3 Main Story'], + ['value' => '70_30', 'label' => '2/3 Main Story + 1/3 Right Sidebar'], + ]), + new SlotField('left_title', FieldType::Text, 'Left Title'), + new SlotField('left_image', FieldType::Image, 'Left Image URL'), + new SlotField('left_body', FieldType::Textarea, 'Left Copy', rows: 3, fullWidth: true), + new SlotField('left_bg_color', FieldType::Color, 'Left Background Color', default: '#F8FAFC'), + new SlotField('right_title', FieldType::Text, 'Right Title'), + new SlotField('right_button_text', FieldType::Text, 'CTA Button Text', default: 'Read Story'), + new SlotField('right_button_url', FieldType::Url, 'CTA Button URL', default: '#'), + new SlotField('right_bg_color', FieldType::Color, 'Right Background Color', default: '#FFFFFF'), + new SlotField('right_body', FieldType::Textarea, 'Right Copy', rows: 3, fullWidth: true), + ], columns: 2, supportsVisibility: true); + } + + private static function testimonial(): SlotSchema + { + return new SlotSchema(SlotType::Testimonial, 'chat-bubble-left-right', [ + new SlotField('quote', FieldType::Textarea, 'Customer Quote', required: true, rows: 3, fullWidth: true), + new SlotField('author', FieldType::Text, 'Author Name', required: true), + new SlotField('role', FieldType::Text, 'Job Role / Title', placeholder: 'e.g. VP of Growth'), + new SlotField('company', FieldType::Text, 'Company Name', placeholder: 'e.g. Acme Corp'), + new SlotField('rating', FieldType::Select, 'Star Rating', default: 5, options: [ + ['value' => 5, 'label' => '★★★★★ (5 Stars)'], + ['value' => 4, 'label' => '★★★★☆ (4 Stars)'], + ['value' => 3, 'label' => '★★★☆☆ (3 Stars)'], + ]), + new SlotField('avatar_url', FieldType::Image, 'Avatar Image URL', advanced: true), + new SlotField('accent_color', FieldType::Color, 'Accent Color', advanced: true), + new SlotField('bg_color', FieldType::Color, 'Background Color', advanced: true), + ], columns: 2); + } + + private static function statBox(): SlotSchema + { + return new SlotSchema(SlotType::StatBox, 'chart-bar', [ + new SlotField('heading', FieldType::Text, 'Section Heading (Optional)', placeholder: 'e.g. Platform Highlights'), + new SlotField('stats', FieldType::Items, 'Key Metrics', fullWidth: true, defaultItems: 2, itemColumns: 3, items: [ + new SlotField('value', FieldType::Text, 'Metric / Number', required: true, placeholder: 'e.g. 99.9% or +45%'), + new SlotField('label', FieldType::Text, 'Label', required: true, placeholder: 'e.g. Uptime SLA'), + new SlotField('change', FieldType::Text, 'Badge / Pill (Optional)', placeholder: 'e.g. +14% YoY'), + ]), + new SlotField('bg_color', FieldType::Color, 'Background Color', default: '#f1f5f9', advanced: true), + ]); + } + + private static function socialLinks(): SlotSchema + { + return new SlotSchema(SlotType::SocialLinks, 'share', [ + new SlotField('links', FieldType::Items, 'Social Profiles', fullWidth: true, defaultItems: 3, itemColumns: 2, items: [ + new SlotField('network', FieldType::Select, 'Network', required: true, default: 'Twitter', options: [ + ['value' => 'Twitter', 'label' => 'Twitter / X'], + ['value' => 'LinkedIn', 'label' => 'LinkedIn'], + ['value' => 'GitHub', 'label' => 'GitHub'], + ['value' => 'YouTube', 'label' => 'YouTube'], + ['value' => 'Facebook', 'label' => 'Facebook'], + ['value' => 'Website', 'label' => 'Website'], + ]), + new SlotField('url', FieldType::Url, 'Profile URL', required: true), + ]), + new SlotField('align', FieldType::Select, 'Alignment', default: 'center', advanced: true, options: [ + ['value' => 'center', 'label' => 'Center'], + ['value' => 'left', 'label' => 'Left'], + ['value' => 'right', 'label' => 'Right'], + ]), + new SlotField('bg_color', FieldType::Color, 'Background Color', advanced: true), + ]); + } + + private static function html(): SlotSchema + { + return new SlotSchema(SlotType::Html, 'code-bracket', [ + new SlotField('html', FieldType::Textarea, 'Raw HTML Code', required: true, rows: 8, fullWidth: true), + new SlotField('bg_color', FieldType::Color, 'Background Color', advanced: true), + ]); + } + + private static function videoCard(): SlotSchema + { + return new SlotSchema(SlotType::VideoCard, 'play-circle', [ + new SlotField('video_url', FieldType::Url, 'Video Destination URL (YouTube, Vimeo, Loom)', required: true, placeholder: 'https://www.youtube.com/watch?v=...', fullWidth: true), + new SlotField('thumbnail_url', FieldType::Image, 'Custom Thumbnail Image URL', placeholder: 'https://images.unsplash.com/...'), + new SlotField('badge', FieldType::Text, 'Badge Text', default: 'VIDEO DEMO'), + new SlotField('title', FieldType::Text, 'Video Title', required: true, default: 'Watch Product Tour & Feature Walkthrough', fullWidth: true), + new SlotField('subtitle', FieldType::Textarea, 'Video Description / Duration', rows: 2, placeholder: 'e.g. 3 min overview of platform features', fullWidth: true), + new SlotField('border_radius', FieldType::Select, 'Corner Style', default: '12px', advanced: true, options: [ + ['value' => '0px', 'label' => 'Sharp (0px)'], + ['value' => '8px', 'label' => 'Rounded (8px)'], + ['value' => '12px', 'label' => 'Soft (12px)'], + ['value' => '20px', 'label' => 'Pill (20px)'], + ]), + ], columns: 2, supportsVisibility: true); + } + + private static function pricingGrid(): SlotSchema + { + return new SlotSchema(SlotType::PricingGrid, 'currency-dollar', [ + new SlotField('heading', FieldType::Text, 'Section Heading', placeholder: 'Simple, transparent pricing'), + new SlotField('subtitle', FieldType::Text, 'Subtitle', placeholder: 'Start free, upgrade as you grow'), + new SlotField('tiers', FieldType::Items, 'Pricing Tiers', fullWidth: true, defaultItems: 2, itemColumns: 3, items: [ + new SlotField('name', FieldType::Text, 'Plan Name', required: true, placeholder: 'Starter'), + new SlotField('price', FieldType::Text, 'Price', required: true, placeholder: '$49'), + new SlotField('frequency', FieldType::Text, 'Billing Frequency', default: '/ month', placeholder: '/ month'), + new SlotField('features', FieldType::Tags, 'Feature Bullets', help: 'One feature per line.', fullWidth: true), + new SlotField('button_text', FieldType::Text, 'CTA Button Text', required: true, default: 'Get Started'), + new SlotField('button_url', FieldType::Url, 'CTA Destination URL', required: true, default: 'https://example.com/signup'), + new SlotField('is_popular', FieldType::Toggle, 'Highlight as Most Popular', default: false), + ]), + ], supportsVisibility: true); + } + + private static function productCatalog(): SlotSchema + { + return new SlotSchema(SlotType::ProductCatalog, 'shopping-bag', [ + new SlotField('section_title', FieldType::Text, 'Section Header', placeholder: 'Recommended For You / Items in Your Cart', fullWidth: true), + new SlotField('section_subtitle', FieldType::Text, 'Section Subtitle', placeholder: 'Hand-picked recommendations based on your preferences', fullWidth: true), + new SlotField('columns', FieldType::Select, 'Columns Count', required: true, default: 2, options: [ + ['value' => 1, 'label' => '1 Column (Large Featured Cards)'], + ['value' => 2, 'label' => '2 Columns (Standard Grid)'], + ['value' => 3, 'label' => '3 Columns (Compact Catalog)'], + ]), + new SlotField('show_badges', FieldType::Toggle, 'Show Discount & Stock Badges', default: true), + new SlotField('card_background', FieldType::Color, 'Card Background Color', default: '#ffffff'), + new SlotField('card_border', FieldType::Color, 'Card Border Color', default: '#e2e8f0'), + new SlotField('products', FieldType::Items, 'Products / Cart Line Items', fullWidth: true, defaultItems: 2, itemColumns: 2, items: [ + new SlotField('title', FieldType::Text, 'Product Title', required: true), + new SlotField('price', FieldType::Text, 'Current Price', required: true, placeholder: '$49.00'), + new SlotField('compare_at_price', FieldType::Text, 'Compare-at / Original Price (Strikethrough)', placeholder: '$79.00'), + new SlotField('savings_badge', FieldType::Text, 'Discount Badge (e.g. Save 30%)', placeholder: 'Save 30%'), + new SlotField('stock_badge', FieldType::Text, 'Stock Badge (e.g. In Stock / Low Stock)', placeholder: 'In Stock'), + new SlotField('image_url', FieldType::Image, 'Product Image URL', required: true), + new SlotField('button_text', FieldType::Text, 'Button CTA Label', required: true, default: 'Buy Now'), + new SlotField('button_url', FieldType::Url, 'Checkout / Product URL', required: true), + new SlotField('description', FieldType::Textarea, 'Short Description', rows: 2, fullWidth: true), + ]), + new SlotField('button_background', FieldType::Color, 'Button Background Color', advanced: true), + new SlotField('button_color', FieldType::Color, 'Button Text Color', default: '#ffffff', advanced: true), + ], columns: 2, supportsVisibility: true); + } + + private static function dynamicFeed(): SlotSchema + { + return new SlotSchema(SlotType::DynamicFeed, 'sparkles', [ + new SlotField('heading', FieldType::Text, 'Section Heading', default: 'Recommended For You', fullWidth: true), + new SlotField('subtitle', FieldType::Text, 'Subtitle', placeholder: 'Curated recommendations based on your preferences', fullWidth: true), + new SlotField('items', FieldType::Items, 'Dynamic Products / Content Items', fullWidth: true, defaultItems: 2, itemColumns: 2, items: [ + new SlotField('title', FieldType::Text, 'Item Title', required: true), + new SlotField('price', FieldType::Text, 'Price / Badge', placeholder: 'e.g. $199 / mo'), + new SlotField('image_url', FieldType::Image, 'Thumbnail Image URL'), + new SlotField('button_url', FieldType::Url, 'Target URL', required: true), + new SlotField('button_text', FieldType::Text, 'Button CTA', default: 'View Details'), + new SlotField('description', FieldType::Textarea, 'Description', rows: 2, fullWidth: true), + ]), + ], supportsVisibility: true); + } + + private static function rssFeed(): SlotSchema + { + return new SlotSchema(SlotType::RssFeed, 'rss', [ + new SlotField('heading', FieldType::Text, 'Feed Title', default: 'Latest Articles & Updates', fullWidth: true), + new SlotField('subtitle', FieldType::Text, 'Subtitle', placeholder: 'Latest news from our RSS syndication feed', fullWidth: true), + new SlotField('items', FieldType::Items, 'Feed Articles', fullWidth: true, defaultItems: 2, itemColumns: 2, items: [ + new SlotField('title', FieldType::Text, 'Article Title', required: true), + new SlotField('published_at', FieldType::Text, 'Published Date', placeholder: 'e.g. Oct 15, 2026'), + new SlotField('author', FieldType::Text, 'Author / Source', placeholder: 'e.g. Editorial Desk'), + new SlotField('url', FieldType::Url, 'Article Link', required: true), + new SlotField('summary', FieldType::Textarea, 'Article Summary', rows: 2, fullWidth: true), + ]), + ], supportsVisibility: true); + } + + private static function accordion(): SlotSchema + { + return new SlotSchema(SlotType::Accordion, 'question-mark-circle', [ + new SlotField('heading', FieldType::Text, 'Section Heading', default: 'Frequently Asked Questions', fullWidth: true), + new SlotField('subtitle', FieldType::Text, 'Section Subtitle', placeholder: 'Quick answers to common questions', fullWidth: true), + new SlotField('items', FieldType::Items, 'Questions & Answers', fullWidth: true, defaultItems: 2, itemColumns: 1, items: [ + new SlotField('question', FieldType::Text, 'Question', required: true, placeholder: 'e.g. Can I upgrade my plan later?'), + new SlotField('answer', FieldType::Textarea, 'Answer', required: true, rows: 2, placeholder: 'e.g. Yes, you can change your subscription anytime in account billing.'), + ]), + ], supportsVisibility: true); + } + + private static function orderReceipt(): SlotSchema + { + return new SlotSchema(SlotType::OrderReceipt, 'shopping-bag', [ + new SlotField('order_number', FieldType::Text, 'Order / Reference #', required: true, default: '#1001'), + new SlotField('order_date', FieldType::Text, 'Order Date', placeholder: 'e.g. October 24, 2026'), + new SlotField('currency', FieldType::Text, 'Currency Symbol', default: '$'), + new SlotField('items', FieldType::Items, 'Purchased Items', fullWidth: true, defaultItems: 1, itemColumns: 4, items: [ + new SlotField('item', FieldType::Text, 'Product / Service Name', required: true), + new SlotField('qty', FieldType::Number, 'Qty', required: true, default: 1), + new SlotField('unit_price', FieldType::Text, 'Unit Price', required: true, default: '0.00'), + new SlotField('total', FieldType::Text, 'Line Total', required: true, default: '0.00'), + ]), + new SlotField('subtotal', FieldType::Text, 'Subtotal Amount', default: '0.00'), + new SlotField('shipping', FieldType::Text, 'Shipping Fee', default: '0.00'), + new SlotField('tax', FieldType::Text, 'Tax / VAT', default: '0.00'), + new SlotField('grand_total', FieldType::Text, 'Grand Total', required: true, default: '0.00'), + new SlotField('notes', FieldType::Textarea, 'Payment Notes (Optional)', rows: 2, fullWidth: true), + ], supportsVisibility: true); + } + + private static function dataTable(): SlotSchema + { + return new SlotSchema(SlotType::DataTable, 'table-cells', [ + new SlotField('heading', FieldType::Text, 'Table Heading (Optional)', placeholder: 'Feature & Tier Comparison', fullWidth: true), + new SlotField('subtitle', FieldType::Text, 'Table Subtitle (Optional)', placeholder: 'Compare available features across plans', fullWidth: true), + new SlotField('headers', FieldType::Tags, 'Column Header Names', help: 'One column name per line.', default: ['Feature', 'Starter', 'Professional', 'Enterprise'], fullWidth: true), + new SlotField('rows', FieldType::Items, 'Table Rows', fullWidth: true, defaultItems: 3, items: [ + new SlotField('cells', FieldType::Tags, 'Row Cell Values (Matching Column Count)', help: 'One cell per line, in column order.', required: true, default: ['Feature name', 'Included', 'Included', 'Included']), + ]), + new SlotField('bg_color', FieldType::Color, 'Background Color', advanced: true), + ], supportsVisibility: true); + } + + private static function couponCode(): SlotSchema + { + return new SlotSchema(SlotType::CouponCode, 'ticket', [ + new SlotField('headline', FieldType::Text, 'Voucher Headline', required: true, default: 'Special Discount Voucher'), + new SlotField('discount_badge', FieldType::Text, 'Discount Badge (e.g. 20% OFF)', default: '20% OFF'), + new SlotField('code', FieldType::Text, 'Promo / Coupon Code', required: true, placeholder: 'SAVE20'), + new SlotField('expires_at', FieldType::Text, 'Expiration Notice', placeholder: 'e.g. October 31, 2026 or In 3 Days'), + new SlotField('button_text', FieldType::Text, 'Redeem CTA Button Text', default: 'Claim Offer'), + new SlotField('button_url', FieldType::Url, 'Redeem CTA URL', default: '#'), + new SlotField('description', FieldType::Textarea, 'Terms or Description', default: 'Apply this promo code at checkout to claim your savings.', rows: 2, fullWidth: true), + new SlotField('card_background', FieldType::Color, 'Voucher Background Color', default: '#f8fafc'), + new SlotField('border_color', FieldType::Color, 'Dashed Border Color', default: '#94a3b8'), + new SlotField('code_background', FieldType::Color, 'Code Box Background Color', default: '#ffffff'), + ], columns: 2, supportsVisibility: true); + } + + private static function countdownTimer(): SlotSchema + { + return new SlotSchema(SlotType::CountdownTimer, 'clock', [ + new SlotField('badge', FieldType::Text, 'Urgency Badge', default: '⚡ LIMITED TIME OFFER'), + new SlotField('deadline_text', FieldType::Text, 'Deadline Description', required: true, default: 'Offer expires Sunday at Midnight'), + new SlotField('heading', FieldType::Text, 'Headline', required: true, default: 'Unlock 20% Off Your Annual Plan', fullWidth: true), + new SlotField('subtext', FieldType::Textarea, 'Promotional Subtext', default: 'Lock in early-bird pricing before this promotional tier expires.', rows: 2, fullWidth: true), + new SlotField('button_text', FieldType::Text, 'Action Button Label', required: true, default: 'Claim Your Discount Now'), + new SlotField('button_url', FieldType::Url, 'Action Destination URL', required: true, default: 'https://example.com/checkout?promo=FLASH20'), + ], columns: 2, supportsVisibility: true); + } + + private static function ratingBar(): SlotSchema + { + return new SlotSchema(SlotType::RatingBar, 'star', [ + new SlotField('question', FieldType::Text, 'Survey Question Prompt', required: true, default: 'How likely are you to recommend us to a colleague?', fullWidth: true), + new SlotField('scale_type', FieldType::Select, 'Rating Scale', required: true, default: '10', options: [ + ['value' => '10', 'label' => '0 to 10 Scale (Industry Standard NPS)'], + ['value' => '5', 'label' => '1 to 5 Stars / CSAT Scale'], + ]), + new SlotField('base_url', FieldType::Text, 'Destination Rating Endpoint / Webhook', required: true, default: '/feedback/nps/{{nps_token}}', help: 'Each button appends /{score} to this URL.'), + new SlotField('low_label', FieldType::Text, 'Lowest Score Meaning', required: true, default: 'Not at all likely'), + new SlotField('high_label', FieldType::Text, 'Highest Score Meaning', required: true, default: 'Extremely likely'), + ], columns: 2, supportsVisibility: true); + } + + private static function appBadges(): SlotSchema + { + return new SlotSchema(SlotType::AppBadges, 'device-phone-mobile', [ + new SlotField('heading', FieldType::Text, 'Section Heading', placeholder: 'Download Our Mobile App', fullWidth: true), + new SlotField('subtitle', FieldType::Text, 'Subtitle', placeholder: 'Manage your account on the go', fullWidth: true), + new SlotField('app_store_url', FieldType::Url, 'Apple App Store Link URL', default: '#', placeholder: 'https://apps.apple.com/app/...'), + new SlotField('google_play_url', FieldType::Url, 'Google Play Store Link URL', default: '#', placeholder: 'https://play.google.com/store/apps/...'), + new SlotField('align', FieldType::Select, 'Alignment', default: 'center', options: [ + ['value' => 'center', 'label' => 'Center Aligned'], + ['value' => 'left', 'label' => 'Left Aligned'], + ]), + new SlotField('bg_color', FieldType::Color, 'Background Color', advanced: true), + ], columns: 2, supportsVisibility: true); + } + + private static function labeledDivider(): SlotSchema + { + return new SlotSchema(SlotType::LabeledDivider, 'minus', [ + new SlotField('label', FieldType::Text, 'Divider Center Label / Badge', required: true, default: 'OR'), + new SlotField('badge_bg', FieldType::Color, 'Badge Background Color', default: '#f1f5f9'), + new SlotField('text_color', FieldType::Color, 'Badge Text Color', default: '#64748b'), + new SlotField('line_color', FieldType::Color, 'Divider Line Color', default: '#e2e8f0'), + new SlotField('padding_y', FieldType::Select, 'Vertical Spacing', default: 24, options: [ + ['value' => 16, 'label' => 'Compact (16px)'], + ['value' => 24, 'label' => 'Standard (24px)'], + ['value' => 36, 'label' => 'Generous (36px)'], + ]), + new SlotField('bg_color', FieldType::Color, 'Background Color', advanced: true), + ], columns: 2, supportsVisibility: true); + } } diff --git a/tests/Feature/EditorRoutesTest.php b/tests/Feature/EditorRoutesTest.php index 7674ccf..b588742 100644 --- a/tests/Feature/EditorRoutesTest.php +++ b/tests/Feature/EditorRoutesTest.php @@ -4,6 +4,8 @@ namespace Odden\MailBuilder\Tests\Feature; +use Odden\MailBuilder\Enums\SlotType; +use Odden\MailBuilder\Schema\SlotSchemaRegistry; use Odden\MailBuilder\Tests\TestCase; class EditorRoutesTest extends TestCase @@ -20,8 +22,8 @@ public function test_the_schema_route_returns_the_slot_schema(): void { $response = $this->getJson('/mail-builder/editor/schema')->assertOk(); - $this->assertSame(1, $response->json('version')); - $this->assertCount(8, $response->json('slots')); + $this->assertSame(SlotSchemaRegistry::VERSION, $response->json('version')); + $this->assertCount(count(SlotType::cases()), $response->json('slots')); $this->assertSame('hero', $response->json('slots.1.type')); } diff --git a/tests/Unit/SlotSchemaTest.php b/tests/Unit/SlotSchemaTest.php index e6cf18e..891ab4d 100644 --- a/tests/Unit/SlotSchemaTest.php +++ b/tests/Unit/SlotSchemaTest.php @@ -15,22 +15,24 @@ class SlotSchemaTest extends TestCase { - public function test_the_described_types_are_the_planned_first_eight(): void + public function test_every_slot_type_is_described(): void { - $this->assertSame( - ['header', 'hero', 'body_text', 'button', 'image_banner', 'features', 'divider', 'footer'], - array_map(fn (SlotType $type): string => $type->value, SlotSchemaRegistry::types()), - ); + $described = array_map(fn (SlotType $type): string => $type->value, SlotSchemaRegistry::types()); + $all = array_map(fn (SlotType $type): string => $type->value, SlotType::cases()); + + $this->assertEqualsCanonicalizing($all, $described); + $this->assertSame($described, array_values(array_unique($described)), 'A type is listed twice.'); } - public function test_every_described_type_has_a_schema_and_the_others_have_none(): void + public function test_every_type_has_a_schema_that_belongs_to_it(): void { foreach (SlotType::cases() as $type) { - if (SlotSchemaRegistry::has($type)) { - $this->assertInstanceOf(SlotSchema::class, SlotSchemaRegistry::for($type), $type->value); - } else { - $this->assertNull(SlotSchemaRegistry::for($type), $type->value); - } + $this->assertTrue(SlotSchemaRegistry::has($type), $type->value); + + $schema = SlotSchemaRegistry::for($type); + + $this->assertInstanceOf(SlotSchema::class, $schema, $type->value); + $this->assertSame($type, $schema->type); } } @@ -108,7 +110,7 @@ public function test_the_schema_exports_as_json_an_editor_can_read(): void $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); $this->assertSame(SlotSchemaRegistry::VERSION, $decoded['version']); - $this->assertCount(8, $decoded['slots']); + $this->assertCount(count(SlotType::cases()), $decoded['slots']); $hero = collect($decoded['slots'])->firstWhere('type', 'hero'); $this->assertSame('Hero Headline & Banner', $hero['label']); @@ -130,14 +132,52 @@ public function test_the_schema_exports_as_json_an_editor_can_read(): void $this->assertSame(2, $items['default_items']); } - public function test_every_slot_type_has_a_label_in_the_export_even_without_a_schema(): void + public function test_every_slot_type_has_a_label_in_the_export(): void { $labels = SlotSchemaRegistry::toArray()['labels']; $this->assertCount(count(SlotType::cases()), $labels); $this->assertSame('Customer Quote & Testimonial', $labels['testimonial']); $this->assertSame(SlotType::Hero->label(), $labels['hero']); - $this->assertNull(SlotSchemaRegistry::for(SlotType::Testimonial)); + } + + public function test_a_list_of_strings_is_a_tags_field_with_array_defaults(): void + { + $table = SlotSchemaRegistry::for(SlotType::DataTable); + $headers = collect($table?->fields)->firstWhere('key', 'headers'); + $rows = collect($table?->fields)->firstWhere('key', 'rows'); + + $this->assertSame(FieldType::Tags, $headers?->type); + $this->assertSame(['Feature', 'Starter', 'Professional', 'Enterprise'], $table?->defaults()['headers']); + $this->assertSame('cells', $rows?->items[0]->key); + $this->assertSame(FieldType::Tags, $rows?->items[0]->type); + $this->assertCount(count($table?->defaults()['headers']), $rows?->items[0]->resolveDefault(), 'A new row has as many cells as the table has columns.'); + + $pricing = SlotSchemaRegistry::for(SlotType::PricingGrid); + $tiers = collect($pricing?->fields)->firstWhere('key', 'tiers'); + + $this->assertSame(FieldType::Tags, collect($tiers?->items)->firstWhere('key', 'features')?->type); + } + + public function test_select_defaults_are_one_of_their_options(): void + { + foreach (SlotSchemaRegistry::all() as $schema) { + foreach ($schema->fields as $field) { + $fields = [$field, ...$field->items]; + + foreach ($fields as $candidate) { + $default = $candidate->resolveDefault(); + + if ($candidate->type !== FieldType::Select || $default === null) { + continue; + } + + $values = array_map(fn (array $option): string => (string) $option['value'], $candidate->options); + + $this->assertContains((string) $default, $values, "{$schema->type->value}.{$candidate->key} defaults to a value it does not offer."); + } + } + } } public function test_advanced_fields_are_marked_and_kept_out_of_the_basic_form(): void diff --git a/tests/js/fields.test.mjs b/tests/js/fields.test.mjs new file mode 100644 index 0000000..009e0d2 --- /dev/null +++ b/tests/js/fields.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { parseLines } from '../../resources/js/editor/fields.js'; +import { setField } from '../../resources/js/editor/model.js'; + +test('a textarea becomes a list of its non-empty lines, trimmed', () => { + assert.deepEqual(parseLines('Up to 5,000 contacts\n A/B testing \r\n\n\nDedicated IP\n'), ['Up to 5,000 contacts', 'A/B testing', 'Dedicated IP']); +}); + +test('an empty textarea is no list, so the slot falls back to its own default', () => { + assert.equal(parseLines(''), ''); + assert.equal(parseLines(' \n \n'), ''); + + const slots = [{ id: 'a', type: 'data_table', data: { headers: ['A', 'B'] } }]; + + assert.equal('headers' in setField(slots, 'a', ['headers'], parseLines(''))[0].data, false); +}); + +test('a list of strings inside a list item is set by its path', () => { + const slots = [{ id: 'a', type: 'data_table', data: { rows: [{ cells: ['x'] }, { cells: ['y'] }] } }]; + const next = setField(slots, 'a', ['rows', 1, 'cells'], parseLines('Pro\nEnterprise')); + + assert.deepEqual(next[0].data.rows, [{ cells: ['x'] }, { cells: ['Pro', 'Enterprise'] }]); + assert.deepEqual(slots[0].data.rows[1].cells, ['y']); +});