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
Two additional themes ship in the box. Both are full bundles covering all seven pages and use the live demo agent for the hero chat preview.
-
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.
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.
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.
Theme-matched auth shells
Both Aurora and Prism ship an auth-shell.tsx alongside
their seven 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();'
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();'