Run your workspace
Pluggable marketing themes
Pluggable marketing themes
The whole marketing site — home, pricing, how-it-works, integrations, privacy, terms, changelog — is a swappable theme. Operators add a new theme by dropping a folder; switching is one click in Settings → System → Marketing.
How it works
Every marketing controller looks up its Inertia component through
App\Support\MarketingTheme::component($page) instead of
hardcoding a string. The resolver reads
app_settings.marketing_theme, falls back to
harvest (the built-in theme) when the column is empty or
points at a slug that isn't installed, and returns either:
- The legacy component path for
harvest(welcome,marketing/pricing,marketing/how-it-works, etc.) — so existing installs keep rendering the original layout. marketing-themes/{slug}/{page}for any other theme — points Inertia at a self-contained per-theme folder.
Add a new theme
-
Create a folder under
resources/js/pages/marketing-themes/named after the theme's slug (kebab-case, no spaces). For example,resources/js/pages/marketing-themes/meadow/. -
Add a
theme.jsonmanifest at the root of that folder. Minimum shape:{ "name": "Meadow", "description": "Calm, editorial layout with green accents." }Themes without a
theme.jsonare ignored — the manifest is what makes a folder a theme.If a theme only ships a subset of the seven pages, add a
pageswhitelist so the resolver knows which keys the theme provides — every other page silently falls back to the Harvest legacy component:{ "name": "Aurora", "description": "Editorial brutalist — home page only.", "pages": ["home"] }Omitting
pagesmeans the theme is assumed to provide all seven; useful when you're shipping a full bundle. -
Drop the seven page components inside the folder, each receiving the same props the matching controller passes today. Names must match exactly:
home.tsxpricing.tsxhow-it-works.tsxintegrations.tsxprivacy.tsxterms.tsxchangelog.tsx
Use
resources/js/layouts/marketing-shell.tsxas the shared shell, or ship your own per-theme shell inside the folder. -
Run
npm run build(or keepnpm run devrunning while you iterate) so Vite picks up the new files. -
Open Settings → System → Marketing, pick the new theme from the dropdown, and save. Visit
/,/pricing, etc. — they now render from your folder.
Props your theme components receive
The controllers pass the same props regardless of theme. Build your page components against this shape and a theme swap is a pure visual change:
| Page | Notable props |
|---|---|
home | canRegister, demoAgentId, content, seo |
pricing | plans, lifetime_plans, currency, currencies, matrix, faqs, shell, brand, seo |
how-it-works | steps, latency, shell, brand, seo |
integrations | native, data_sources, roadmap, shell, brand, seo |
privacy | content, shell, brand, seo |
terms | intro, sections, effective_date, contact_email, shell, brand, seo |
changelog | entries, shell, brand, seo |
Built-in: the Harvest theme
The shipped theme is called harvest. For back-compat its
files live where they always did
(resources/js/pages/welcome.tsx +
resources/js/pages/marketing/*.tsx) rather than under
marketing-themes/harvest/. The resolver maps to those
legacy paths so existing installs upgrade with no rendering
difference.
Clearing a copy field
Every text field in the landing page editor can be left empty on purpose. Clear it, save, and it stays cleared — the shipped default does not come back. The hero headline drops any line whose slots are blank, so a three-line headline is written by emptying the fourth slot rather than by hiding it in CSS.
A field your stored content has never mentioned is a different case and still inherits its default. That is what carries newly shipped keys — a new setting added in an upgrade — to installs that saved their marketing copy long before the key existed.
Animating the hero preview card
The Harvest hero's right-hand card can play a silent looping animation instead of the static chat mockup. Set Landing page content → Hero preview card → Animated preview video to a video URL, and optionally a poster image. Leave it empty and the card is built from the copy fields as before.
This is on out of the box, pointing at
/marketing/hero/hero-card-{locale}.mp4 — two
pre-rendered clips, 30 s and ~137 KB each, silent and
seamlessly looping. The card chrome never moves; only the
conversation inside changes, through six chapters that each start
and end empty:
- Answers from the operator's own pages, source chip visible
- Asks a qualifying question back, with quick-reply chips
- Recommends a product, with price and stock
- Answers in three languages
- Books an appointment from open time slots
- Hands the conversation to a person and pings them on WhatsApp
A caption pill above the footer names the capability on screen — a
hero is glanced at rather than read, so the changing caption is what
carries the breadth. The source composition lives at
video/src/hero/HeroShowcase.tsx; re-render it with
npx remotion render src/index.ts HeroShowcaseNL from
the video/ directory.
No poster ships for it on purpose: the clip opens on an empty card, which is what the card's own background already shows, so a poster would only cost bytes and flash a mismatched frame. Set one if you replace the clip with something that opens on a busy frame.
The clip is rendered edge-to-edge because MP4 carries no alpha channel — the rounded corner, border, and shadow come from the page's own card shell. A replacement clip should therefore be 4:5 and fill its frame. This setting is Harvest-only; Aurora and Prism keep their own hero treatments.
4:5 is deliberate: the hero card has to end above the fold on a laptop. At the hero's 494 px column that is 617 px tall, and the card is additionally capped against the viewport, so a shorter screen shrinks the whole card rather than pushing its bottom — the caption and the footer — out of sight.
The walkthrough video
Landing page content → Video walkthrough → Video href accepts three kinds of value:
- A Vimeo or YouTube link — played in a modal iframe, thumbnail fetched from the host.
-
A self-hosted file ending in
.mp4,.webm,.movor.m4v— played in the same modal with a native player. Set Video thumbnail as well, since there is no host to fetch a poster from. - Anything else — the card becomes a plain link and navigates normally.
A self-hosted film ships in the box and is the default —
/marketing/video/blengi-demo-{locale}.mp4 (72 s,
720p, ~1.2 MB) with matching posters. Nothing is downloaded
until a visitor clicks play: the card loads only the poster image,
and the player is mounted when the modal opens.
Per-language video files
Write {locale} anywhere in a video or thumbnail field
and it resolves against the visitor's language when the page renders,
so one setting serves every language. A visitor reading Dutch gets
hero-card-nl.mp4, one reading English gets
hero-card-en.mp4.
A language you have not filmed falls back to the English file. If
that is missing too, the hero quietly returns to its static card
rather than pointing a player at a 404. A value with no
{locale} in it — your own Vimeo link, your own upload —
is never rewritten.
Shipped extra themes
Three additional themes ship in the box. Aurora and Prism are full bundles covering all seven pages and use the live demo agent for the hero chat preview; Blengi currently claims the home page only.
-
Aurora (slug
aurora) — editorial brutalist with a paper/ink palette and an electric-lime signal accent. Lives atresources/js/pages/marketing-themes/aurora/. Shipsauth-shell.tsxso login, register, and password-reset flows render in the same paper/ink/lime palette as the marketing site. -
Prism (slug
prism) — purple/coral gradient identity, Inter Tight body with Instrument Serif italic accents, glossy hero mockup with floating context cards, gradient-bar footer. Lives atresources/js/pages/marketing-themes/prism/. Also shipsauth-shell.tsxfor theme-matched sign-in. -
Blengi (slug
blengi) — clean SaaS: a coral accent on a near-white ground, Poppins display over a Manrope body, a real customer screenshot in the hero behind a floating chat card and lead card, and a pricing teaser wired to livePlanrows. Lives atresources/js/pages/marketing-themes/blengi/. Its fonts are served from the site itself (public/fonts,fonts.css; Latin + Latin Extended, OFL), not from Google Fonts, which would send every visitor's IP address to Google before any consent (#764)./linkedinuses the same files. Aurora and Prism still load theirs from Google Fonts.
The Blengi theme's extra content keys
This theme draws six sections the older themes do not, so
MarketingHomeContent::defaults() gained matching keys.
They are additive — an install that saved its content before these
existed inherits them on the next render, and every one of them is
editable at
Settings → Marketing content.
hero_visual— the browser frame, its screenshot and alt text, the click-through ribbon, and the copy inside the floating chat and lead cards.logos— the "trained on the tools you already use" strip, as plain wordmarks rather than third-party logos.trust— the three check-marked claims.problem— the headline plus three sample questions, the last flaggedhighlightto flip it from the unanswered treatment to the answered one.roles— the three role cards.agencies— the white-label card beside insights.pricing_teaser— copy only. The plan names, prices and the "most popular" flag come from the live plan rows, so the homepage can never disagree with the pricing page. Setenabledtofalseto hide the section and skip the query entirely.
Two details are easy to get wrong when extending this theme. First,
the hero's reassurance chips may contain the token
{from_price}, which is replaced at render time with the
cheapest live monthly price; a chip whose token cannot be resolved is
dropped rather than printed raw. Second, the translation collector
skips any key whose name contains href,
url, icon, image,
color or src as a substring — which is why
the hero screenshot's alt text is stored as
capture_alt. A field named image_alt would
never reach the translation manager.
Nav dropdowns and the footer language row
A nav item in marketing_home_content.nav_items may carry
children (a list of {label, href}). The Blengi
theme renders such an item as a dropdown on desktop — a real button with
aria-expanded, opened on hover or click, closed on Escape,
outside click or choosing a child — and as an indented group in the
phone drawer. The first default nav item carries an empty
children key on purpose: the content merge walks the
template's keys, so without it an operator save would drop every
dropdown. Other themes ignore children and render the item's
own href.
The Blengi footer writes the enabled languages out by name
("English · Nederlands · Deutsch"), the current one emphasised, and
switches through the same /locale/switch endpoint as the
shared LocaleSwitcher, so the choice is saved and the localised twin of
the current page renders.
The header carries the shared flag picker as well (#760), next to "Log in" on wide screens and next to the menu button on phones, so a visitor can change the language without scrolling. It shows only when more than one language is enabled.
Links inside a theme
A theme must not render a content-driven href as a bare <a>:
every click then becomes a full document load, the widget re-mounts and
the scroll position is lost, which is what the Blengi theme did until
#632. Route such links through the theme's SmartLink
(marketing-themes/blengi/_link.tsx): an in-app path becomes
an Inertia <Link>; the Blade documentation site,
/sitemap.xml, /robots.txt, custom
/p/… pages, files and other origins stay plain anchors,
because an Inertia visit to a non-Inertia response shows the error
overlay instead of the page. BlengiThemeTest fails on a bare
anchor with a content-driven href.
Prism try-now demo (anonymous URL ingest)
Prism's hero ships an interactive Try it form. A visitor
pastes a URL, the server fetches the page synchronously
(POST /api/v1/widget/try-now), extracts readable text
via HtmlExtractor, chunks it, and stashes the chunks
under a short-lived cache token (1h TTL). The hero chat then
switches to that cached context — every visitor message routes
through POST /api/v1/widget/try-now/stream, which
streams an LLM reply grounded in the cached chunks via
<source> tags. Both calls carry the page's
locale in the request body, so the opening line and the
error messages come back in the language of the page the demo is on
(#748): these routes run in the api middleware group, which sets no
locale of its own, and the prism theme sends the call without cookies.
Lives at app/Services/TryNow/TryNowSession.php and
app/Http/Controllers/Widget/TryNowController.php. No
agent, no workspace, no DB writes — it can't pollute tenant data.
Rate-limited per IP via the try-now-start and
try-now-stream limiters defined in
AppServiceProvider::configureRateLimiting.
The Blengi theme starts the same demo from its hero form and from the
closing form at the bottom of the homepage. The demo takes the place of
the hero picture, which sits below the fold on a laptop and is hidden on
phones, so starting it scrolls the demo into view (instantly under
prefers-reduced-motion) and, on phones, shows the picture's
slot while the demo runs (.bl-visual.is-demo). Submitting
the same address again scrolls back to the running demo, and
Try another address scrolls back to the hero form. Without this
the button looked dead: the demo started where nobody could see it
(#717). Every other page's form sends the address to
/demo?url=…, where the conversation starts on arrival.
LinkedIn landing page (/linkedin)
A single page for visitors from the founder's LinkedIn profile (#752).
One goal: the visitor leaves their website URL and email (name
optional) to request a free demo, which the founder builds by hand and
sends within 24 hours; nothing is built automatically. Dutch only, one
column, no menu, noindex (meta tag and
X-Robots-Tag), Open Graph title
"Wat zou jouw website antwoorden?" with the site's
og-image.png, and not in sitemap.xml.
/nl/linkedin redirects to it, so blengi.nl links land too.
- Page —
LinkedInLandingController@showrendersresources/views/landing/linkedin.blade.php, a light server page (inline CSS, Poppins + Manrope, a few lines of JavaScript) rather than an Inertia page, because every Inertia page carries the full translation dictionary (~580 KB) and the brief asks for a mobile load under 2 seconds. All text lives inlang/nl/linkedin.php; an English page is alang/en/linkedin.phpwith the same keys. - Form —
POST /linkedin(StoreDemoRequest): the URL is normalised (spaces stripped,https://added, host lowercased) and must be a dotted public host; the email must be valid. Errors are Dutch and appear under the field (or, without JavaScript, after a redirect back to the form that was sent). A filled honeypot (hp_field_7c) is thanked and dropped. The named limiterlinkedin-demoallows 5 requests per IP per hour. On success the form is replaced by "Bedankt! Je krijgt binnen 24 uur een link." — the visitor gets no email. - Storage — table
demo_requests(DemoRequest): url, email, name, source (linkedin), utm_source, utm_medium, utm_campaign, referrer, status (new→built→sent→converted, defaultnew),agent_id(the demo AI employee built for the request, nullable, cleared when that AI employee is deleted),agent_notice_sent_at,agent_deletes_atandagent_deleted_at(the demo clean-up below), timestamps. UTM parameters from the landing URL and the referring page are kept insessionStorageuntil the form is sent, and also written into the form server-side for visitors without JavaScript. - Admin list — Admin › Demo requests
(
/admin/demo-requests, super_admin only,Admin\Platform\DemoRequestController, #765): date, website, email, name and UTM campaign (hover for the source), newest first, 50 a page. Clicking Status sorts by the pipeline order (new, built, sent, converted) and clicking again goes back to date order. Each row has a status menu and a Demo AI employee menu (the 200 newest AI employees across all workspaces); a change saves at once (PATCH /admin/demo-requests/{id}). The link tells the 30-day demo clean-up which AI employee belongs to a request; an AI employee can be the demo of one request only. Under the menu each row says when its demo is deleted (and whether the notice went out), that it was deleted, or that it is never deleted automatically. - Demo clean-up —
pitchbar:expire-demo-agents(nightly at 03:35 UTC;--dry-runlists what it would do, #766) deletes the demo AI employee linked to a request 30 days after it was created, unless the request isconverted. Five days before, one Dutch e-mail (DemoAgentsExpiring) lists every demo that reached day 25 with its deletion day, sent to the address that gets new requests (LINKEDIN_DEMO_NOTIFY_TO, else the platform sender). To keep a demo, set its request to Converted; linking another AI employee also cancels the notice. Nothing is deleted without a sent notice: a failed mail stamps nothing and is tried again the next night, and the deletion is never sooner than five days after the notice, so a demo linked late still gets its five days. Only an AI employee created at or after the request counts as built for it; an older one linked by mistake is never deleted. The delete is the usual one (PurgeAgentVectorsJob, then a hard delete that takes the conversations, sources and chunks along), written to the audit log asagent.deletedwith reasondemo_expired. Days are calendar days in UTC. - Email —
DemoRequestReceived(queued, plain HTML: the visitor's own text is never rendered as markdown) goes toLINKEDIN_DEMO_NOTIFY_TO, or the platform's sender address when that is empty, with every field; Reply-To is the visitor. - Analytics — the marketing site's GA4 setting
(see Analytics and cookie consent below; the compact banner
here). After consent: the page view,
demo_form_start(first focus in a form) anddemo_request(a request accepted, #757), both with the storedutm_source,utm_mediumandutm_campaign; nothing personal is sent. The earlierLINKEDIN_GA4_IDand PostHog keys are gone (#759). - Voor wie — one card per audience (Webshops, MKB,
Bureaus): a label and one sentence, both from
linkedin.audienceinlang/nl/linkedin.php(#757). - Founder photo —
public/marketing/linkedin/founder.jpg, a square 192 px crop shown as a 64 px circle and loaded lazily; left out when the file is missing (LINKEDIN_FOUNDER_PHOTOto change the path).
Analytics and cookie consent
Google Analytics 4 for the public marketing site is set in
Settings → Marketing site → Analytics: a switch and the
measurement ID (G-…, required to switch it on; pasted ids
are upper-cased). Stored on app_settings
(marketing_ga4_enabled, marketing_ga4_measurement_id),
read by MarketingAnalytics::measurementId() (#759).
- Where — every marketing page (the Inertia
components
welcome,marketing/*andmarketing-themes/*) and/linkedin(a smaller banner). Sign-in, app and admin pages never carry it. - Before consent nothing loads. The banner
(
resources/views/partials/analytics-consent.blade.php, plain script, no library) asks first; Accept and Decline look the same. Accept loads gtag.js with Google Consent Mode v2 defaults (analytics granted, advertising denied); Decline loads nothing. The choice is kept for 12 months in the first-partypb_consentcookie. - Changing your mind — "Cookie settings" in the
footer of every theme (
CookieSettingsButton, shown while the shared propcookieConsentis true) opens the banner again;/linkedin, which has no footer, shows it after the privacy sentence under both forms (#763). Declining after accepting stops GA4 and removes its_gacookies. - More information — the banner links to the privacy
page's cookie paragraph (
…/privacy#cookies, #763). - Only marketing pages are measured. When an Inertia
visit moves on to the login screen or the app, the banner script sets
ga-disable-<ID>, so those pages never reach GA4. - Placement — bottom-left on wide screens (clear of the chat launcher), full width on phones, above the Blengi theme's tab bar.
Privacy page (/privacy)
The policy text is edited in Settings → Privacy & GDPR and written in English, the source language. Every language reads it through the translator, like the rest of the marketing copy (#758): the Translation Manager lists each paragraph (the two e-mail addresses excepted), and a missing translation shows the English text.
- Extra sections — a title and one paragraph each,
added under Rights & GDPR in the editor and shown after
"How long we keep it" (after the GDPR block in the Aurora theme). At
most ten; a section without a title or text is dropped on save. Each
gets an anchor from its English title, the same in every language
(
#cookies,#demo-requests, #763). - Demo requests — blengi's policy has a "Demo
requests" section for the
/linkedinform. It promises deletion after 12 months, soDemoRequestisPrunableandmodel:pruneruns daily at 03:50.
Theme-matched auth shells
Aurora, Prism and Blengi each ship an auth-shell.tsx alongside
their marketing pages. The dispatcher at
resources/js/layouts/auth-layout.tsx picks the right
shell based on marketingTheme (the shared Inertia
prop). When the active theme doesn't ship a shell, the dispatcher
falls back to the default Harvest two-panel layout. To add a new
theme's auth shell:
- Create
resources/js/pages/marketing-themes/<slug>/auth-shell.tsxexporting a component with{ title, description, children }props. - Add a branch in
auth-layout.tsx:if (marketingTheme === '<slug>'). - Add a Pest test under
tests/Feature/Marketing/that hits/loginand/registerwith the theme active.
Flip between them under Settings → System → Marketing, or via tinker:
php artisan tinker --execute 'App\Models\AppSetting::singleton()->forceFill(["marketing_theme" => "prism"])->save();'
The Blengi auth shell (#641)
With the Blengi theme active, every auth screen (login, register, forgot and reset password, email verification, password confirmation, the two-factor challenge and the invitation screen) is drawn like the marketing site: the same top bar and logo with a Back to site link, Poppins headings over Manrope text, the coral primary button and the white capture card.
| Width | Layout |
|---|---|
| 1024px and up | An intro column (pill, heading, optional paragraph, check list) beside the form card, inside the 1120px content width. |
| 768 to 1023px | The form card alone, centred, at most 480px wide. The intro is hidden so the form is the first thing on screen. |
| 767px and below | The marketing site's 56px phone bar, 20px card padding, 48px fields and button. Field text is 16px so iOS Safari does not zoom in when a field is focused. |
The intro copy comes from Settings → Branding (auth
aside eyebrow, heading, paragraph and bullets). Without bullets of your
own, the check list repeats the homepage's trust claims, so those claims
are edited in one place. They reach the auth screens through the
authTrust shared prop, which is null on every
other page and under every other theme.
The shell paints one light look whatever the visitor's system theme is,
like the marketing site. It lives outside .blengi-root on
purpose: that root resets margin and padding on every descendant, which
would flatten the forms. Its stylesheet,
blengi-auth.css, mirrors the brand tokens of
blengi.css, and a test fails when the two drift apart.
Restyling the shared auth controls
Every class string in resources/js/pages/auth/styles.ts
starts with a marker class that carries no style of its own:
auth-input, auth-label,
auth-primary, auth-link,
auth-helper, auth-notice,
auth-checkbox, auth-otp-slot and
auth-panel. A theme's auth shell targets those markers
(.bl-auth .auth-input { … }) instead of raw element
selectors. Theme CSS is unlayered, so it wins over the Tailwind utilities
without !important. To recolour the shadcn internals (focus
rings, the active code box, error text) in light and dark mode alike,
re-point the --color-* variables on the shell's root, as
blengi-auth.css does.
The Blengi theme on phones (#638)
Below 768px the Blengi theme renders the client's mobile-reference: an app-like layout rather than the desktop sections stacked. It is one responsive tree, not a second set of pages — the markup is the same on every width and the breakpoint decides, so server rendering and the browser agree.
- Top bar: 56px, the logo, the page name in coral
(
mobileLabelonBlengiPage; landing pages take it frommobile_label, the eyebrow when empty) and a hamburger that opens the drawer with the nav links, Log in and the primary button. - Bottom tab bar: 72px, always visible — Home · Solutions · Pricing · Ask AI. The active tab follows the path: pricing, demo (Ask AI), the solutions, compare and integration sub-pages (Solutions), everything else Home. Every page reserves the bar's height at the bottom.
- Ask AI opens the marketing widget. On phones the
widget's floating launcher and tooltip are hidden (they used to cover
the URL box and the CTA); the tab reveals the widget and opens it,
and closing the panel hides it again. Without a widget on the page
the tab goes to
/demo. - Hero at the phone scale (30px title, dot pill, the
capture as a card with a grey field and a 48px button). Sections
become
8px 20px 40pxblocks with a small coral label and a 22px heading; cards get 16px corners; chip rows scroll sideways. - FAQ accordion: one shared component
(
_faq.tsx) for the home page, the pricing page and the landingfaqblocks. Desktop shows every answer as before; phones show the first and toggle the rest. Items the client leaves out on phones carrymobile_hidden. - Phone-only copy lives next to the desktop copy in
the content models:
mobile_eyebrow,mobile_titleandmobile_descriptionon section heads,hero.mobile_live_label,hero.mobile_description,hero.mobile_site_test_placeholder,trust.mobile_line,pricing_teaser.mobile_compare_label,final_cta.mobile_title+mobile_description, andfooter.mobile_description+footer.mobile_linksfor the compact footer. Empty means "same as desktop". - The home page hides the hero visual, the insights/agencies pair and the SEO block on phones and shows one plan card (the popular one); the pricing page hides the add-on row, the comparison table and the chips, and draws the Enterprise strip as a tinted card.
Resetting if a theme breaks
If a theme's folder is deleted, its manifest becomes invalid, or the
slug stored in app_settings.marketing_theme doesn't match
any installed theme, the resolver silently falls back to
harvest. The marketing site can't be blanked by a stale
setting. To reset explicitly, run:
php artisan tinker --execute 'App\Models\AppSetting::singleton()->forceFill(["marketing_theme" => "harvest"])->save();'