Hyphen User Guides

Conversion Surfaces

Comprehensive technical reference for conversion surfaces — modals, banners, slide-ins, sticky notifications, and inline prompts that drive registrations, subscriptions, and newsletter signups on the Reader Portal, with targeting rules, frequency control, and impression/conversion analytics.

Version 1.2|Updated 2026-06-23|QA Team, Customer Success, Product Stakeholders, Marketing, Engineering

What Are Conversion Surfaces?

Conversion Surfaces are configurable promotional units — modals, banners, slide-in panels, sticky notifications, and inline embeds — that appear on the Reader Portal to nudge readers toward a desired action: creating a free account, subscribing to a paid plan, joining the newsletter, redeeming a coupon, or visiting a campaign, event, or product page.

They are not random pop-ups. Each surface is created and tuned in the Admin Console by the marketing team, stored centrally in PostgreSQL, and then served to the Reader Portal where a targeting engine decides — per reader, per page, per visit — whether the surface is eligible, when it should fire, and how often it may reappear. Every display and interaction is tracked so the team can measure exactly which surfaces convert.

A single surface knows:

  • Who to show to — anonymous visitors, registered (free) readers, paid subscribers, newsletter segments
  • Where to show — which page types, devices, URLs, content sections/tags, and traffic sources (UTM)
  • When to fire — on page load, after time on page, at a scroll depth, on exit intent, after N page views, or after an article is read
  • How often — a frequency cap per session/day/week/month, plus dismiss and conversion suppression
  • What to say — title, subtitle, body, image, theme, primary/secondary CTAs
  • What to offer — a linked coupon (auto-applied at checkout), subscription plan, campaign, event, product, or newsletter

The primary process flow, from authoring to measurement, looks like this.

End-to-end conversion surface flow — authored in Admin, served and rendered on the Reader Portal by the targeting engine, with every interaction tracked back to analytics.

Business Objectives

ObjectiveHow Conversion Surfaces Help
Grow registered reader baseShow registration prompts to anonymous visitors after they engage with content
Convert free readers to paid subscribersPresent subscription offers at high-intent moments (after finishing an article, on paywalled content)
Grow newsletter audienceOffer newsletter sign-up at contextually relevant points
Promote campaigns & eventsDrive awareness for literary events, contests, and outreach campaigns
Drive e-commerce salesPromote bookstore products to engaged readers
Recover abandoning visitorsCapture attention on exit intent before readers leave the site
Distribute discount codesDeliver coupon codes through targeted, timed surfaces that auto-apply at checkout
Measure marketing effectivenessTrack impressions, clicks, dismissals, conversions, and attribution via internal analytics and GA4

How It Works (Behind the Scenes)

A conversion surface lives in three tiers. The Admin Console (:3000) is where it is authored, stored, and reported. PostgreSQL/Prisma is the single source of truth (conversion_surfaces and conversion_events tables). The Reader Portal (:3002) fetches eligible surfaces, evaluates client-side triggers and frequency caps, renders the winning surface, and posts events back.

Data flow: Admin authors → Postgres stores → Cron manages lifecycle → Reader Portal evaluates and renders → events flow back to conversion_events and the stats dashboard / GA4.

Server vs. client responsibilities

DecisionWhere it happensWhy
Targeting match (audience, page type, device, UTM, URL include/exclude)Server (/api/public/conversion-surfaces/eligible)Targeting rules and active status are authoritative in the DB; only eligible surfaces leave the server.
Coupon validity (active, not expired, not exhausted), linked-entity validityServerAvoids ever shipping a dead coupon/plan to the browser.
Priority ordering (highest wins)Server (orderBy: priority desc)Conflict resolution is centralized.
Trigger firing (page_load, time, scroll, exit intent, page views, after read)ClientThese are real-time browser signals.
Frequency cap + dismiss/conversion suppressionClient (localStorage) for anonymous; server-assisted for authenticated readersFast checks for anonymous; cross-device accuracy for logged-in readers via conversion_events.
Event recording + counter incrementServer (/api/public/conversion-surfaces/event)Single write path keeps conversion_events and counters consistent.

Lifecycle cron

A background job (/api/cron/conversion-surface-lifecycle) runs on a schedule and performs two updateMany operations:

  1. status = scheduled AND startsAt <= nowactive
  2. status = active AND endsAt < now (endsAt not null) → expired

So a scheduled surface goes live, and an active surface expires, within one cron interval (~15 minutes) of its boundary time.

Reader Portal component architecture

RootLayout
  └─ GlobalConversionSurfaces            (detects page type from URL, applies page suppression)
       └─ ConversionSurfaceProvider      (fetches eligible surfaces from the API)
            └─ ConversionSurfaceRenderer (evaluates triggers + frequency, picks the winner)
                 ├─ ModalSurface
                 ├─ StickyNotificationSurface
                 ├─ SlideInSurface
                 ├─ BannerSurface         (top_full / bottom_full)
                 └─ InlineEmbedSurface

Concepts: Surface Types, Targeting & Analytics

Surface types and placements

The surfaceType enum has six values, and placement is a separate enum controlling on-screen position.

Surface type (surfaceType)Typical placement (placement)Dismissible?Best for
modalcenterYes (X, backdrop, Escape)High-priority prompts, exit-intent captures
sticky_notificationbottom_right (default), bottom_left, top_right, top_leftYes (X)Low-disruption nudges, newsletter signups
slide_inbottom_right / side panelYes (X, backdrop, Escape)Detailed offers, plan comparisons
top_bannertop_fullYes (X)Site-wide announcements, time-limited offers
bottom_bannerbottom_fullYes (X)Persistent subtle CTAs
inline_embedinlineNo (part of content)Native, editorial-feeling promotions inside articles

Valid placement values: center, bottom_right, bottom_left, top_right, top_left, top_full, bottom_full, inline.

Conversion surfaces list in the Admin Console
The conversion surfaces list — every surface with its type, goal, status, priority, schedule, and performance counters. Filter by status, type, and goal; sort by any metric.

Conversion goals

Each surface has exactly one goal, which defines what counts as a conversion.

GoalReader actionTypical audience
registerCreate a free accountAnonymous
loginSign back inReturning anonymous
subscribe_paidPurchase a subscriptionAnonymous + registered
subscribe_newsletterJoin the email newsletterNot-yet-subscribed
view_campaignVisit a campaign pageAll / targeted
view_eventRSVP / view an eventAll audiences
redeem_couponUse a promo codeTargeted
view_productVisit a shop productAll audiences
visit_pricingOpen the pricing pageNon-subscribers
custom_ctaAny custom-URL actionAny audience

The targeting model

Targeting lives in a single targetingRules JSON column. Every dimension is optional — an omitted or empty array means "match all" for that dimension. Logic is AND between dimensions and OR within a dimension. A reader must satisfy all present dimensions to be eligible. The server evaluates these in /api/public/conversion-surfaces/eligible.

DimensionFieldBehavior
Audienceaudiences[]anonymous, registered, subscriber, newsletter_subscribed, newsletter_unsubscribed. Reader must be in at least one.
Page typepageTypes[]homepage, article, section, archive, search, tag, author, subscribe/pricing, shop/product, campaign, event, issue, newsletter
DevicedeviceTypes[]desktop (≥1024px), tablet (768–1023px), mobile (<768px)
URL excludeexcludeUrls[]If the current path contains any pattern, suppress. Exclude wins over include.
URL includeincludeUrls[]If set, the current path must contain at least one pattern.
Content (article pages)contentTypes[], sections[], tags[]Match the article's content type / section / any tag.
UTMutmSource[], utmMedium[], utmCampaign[]Match the arriving reader's UTM parameters.

After targeting, the server sorts eligible surfaces by priority descending. The Reader Portal then applies client-side trigger and frequency/suppression checks. The full evaluation funnel:

Targeting evaluation (audience → page → device → URL → UTM/content) feeds suppression and frequency checks, then the client trigger; the event funnel runs impression → interaction → conversion.

Triggers (when a surface fires)

The trigger lives in the trigger enum plus three flat numeric columns. Eligibility is server-side; trigger firing is client-side.

Trigger (trigger)Fires whenConfig column
page_loadImmediately on page load
time_on_pageAfter N seconds on the pagetriggerDelaySeconds
scroll_depthAfter scrolling past N% of the pagetriggerScrollPercent
exit_intentCursor moves toward browser chrome (desktop only; mobile falls back to a scroll/time signal)
page_view_countAfter N page views this sessiontriggerPageViews
after_article_readAfter the reader reaches the end of an article (article pages only)
manualFired programmatically (reserved)

Frequency, suppression & the event funnel

Each surface caps how often a reader sees it. Suppression is checked before frequency, in this order: conversion suppression → dismiss suppression → frequency cap.

ControlFieldDefaultMeaning
Frequency limitfrequencyLimit1Max displays in the window
Frequency windowfrequencyUnitsessionsession, day, week, month
Dismiss suppressionsuppressAfterDismiss1Days hidden after a dismiss (0 = current session only)
Conversion suppression (this surface)suppressAfterConversiontrueNever show again after the reader converts on it
Goal-already-met suppressionsuppressIfConvertedtrueNever show if the reader already met the goal (e.g. already registered)
Auto-dismissautoDismissSecondsnullAuto-close after N seconds (null = stays open)

The event funnel recorded for every surface: impression → click / coupon_copied → conversion, with dismiss as the negative outcome. Frequency state for anonymous readers lives in localStorage (cs_freq_*, cs_dismiss_*, cs_converted_*); for authenticated readers it is also checked server-side against conversion_events.


Adjacent Conversion Entry Points

The Admin-authored surfaces above are not the only conversion touchpoints on the Reader Portal. Three first-party, code-driven entry points sit alongside them and share the same goal — moving a reader from prompt to account or subscription. They are not configured under Marketing → Conversion Surfaces and are not tracked in conversion_events; they are part of the reader experience itself. They are documented here so QA and Customer Success know the full conversion picture.

The paywall overlay

When a reader hits a gated article (registration wall, free-article limit, or a metered allowance), the Reader Portal renders the PaywallOverlay — the single most important conversion surface that is not a marketing surface. Its copy and CTAs adapt to the gate the reader hit:

VariantHeadlinePrimary CTANotes
Registration wall (anonymous)"Pause here for now.""Sign in or Create an Account" (opens the auth dialog)One CTA covers both sign-in and account creation. Admin can override label/URL.
Free-article limit (logged in)"Free Article Limit Reached""Subscribe for Unlimited Access" → /subscribePlus a "View subscription plans" secondary link.
Metered allowance used"We'll keep this page bookmarked.""Subscribe"/subscribe (or /subscribe?plan=… when a plan is recommended)Body: "Beyond this page lies more stories, languages, and literary discoveries. Subscribe now to continue reading." There is no secondary link on this branch.

The metered overlay copy and CTA use the brand voice: the headline reads "We'll keep this page bookmarked.", the body is the line above, and the default CTA label is "Subscribe". The subscription/metered branch has a single Subscribe button and no secondary "View subscription plans" link (the primary Subscribe button already goes there). When a specific bucket is exhausted, the headline becomes bucket-specific (e.g. "You've used all N of your premium articles"). Admin-configured subscribeCtaText / subscribeCtaUrl always win over these defaults. See Paywall & Access for how gates are decided.

The reader sign-in dialog and the terms-acceptance gate

The overlay's auth CTA opens a shared sign-in dialog (AuthFlow). The dialog enforces an explicit terms-acceptance gate — the reader must tick "I agree to the Terms of Service and Privacy Policy" before they can proceed:

  • On the email step, the "Continue with Google" button is disabled until the terms checkbox is checked. The gate is an explicit, required checkbox rather than a passive notice.
  • On the account-creation (OTP) step, the Create-account / Sign-in submit button is disabled until the terms checkbox is ticked. A separate, optional newsletter opt-in checkbox sits above it.
  • The gate also appears on the sign-in step for returning readers who lack a recorded termsAcceptedAt (e.g. bulk-created accounts), so consent is captured before they continue.

Consent is recorded with the account, so a reader is asked only until they have accepted. This means a conversion driven by any surface whose goal is register/login lands on a dialog that cannot complete without explicit consent.

The gated /start route (email CTAs)

Email welcome and onboarding CTAs ("Start Reading", verify-email follow-ups) link to /start rather than the bare homepage. /start is a server-side redirect with no rendered UI:

  • Signed in → redirect to the homepage to start reading.
  • Not signed in → redirect to /login?callbackUrl=/ (sign in first, then land on the homepage).

This ensures a reader who opens an email on a device where they are not authenticated is funnelled through sign-in instead of silently landing on the public homepage with no prompt — closing a conversion leak between email and the portal. The route is marked noindex so it never surfaces in search.

Adjacent conversion entry points — the paywall overlay routes to either the terms-gated auth dialog or /subscribe checkout, and the gated /start route funnels email readers through sign-in. None of these are tracked in conversion_events.

Configuration Reference

Every field on the Create/Edit form. UI path prefix: Admin Console → Marketing → Conversion Surfaces → New / Edit. Roles refer to the permission required to set the field (all editing requires MARKETING_CONVERSION_SURFACES_CREATE or _UPDATE).

Basic information

Field / OptionWhat it doesTypeMandatory?DefaultWhere configuredRole
NameInternal admin label (not shown to readers)stringYesBasic InformationCREATE/UPDATE
Surface TypeVisual formatenum (6)YesBasic InformationCREATE/UPDATE
PlacementOn-screen positionenum (8)NocenterBasic InformationCREATE/UPDATE
Conversion GoalDefines what counts as a conversionenum (10)YesBasic InformationCREATE/UPDATE
PriorityHigher number wins when multiple are eligibleintegerNo0Basic InformationCREATE/UPDATE

Content & call-to-action

Field / OptionWhat it doesTypeMandatory?DefaultWhere configuredRole
TitleHeadline shown to the readerstringYesContentCREATE/UPDATE
SubtitleSupporting linestringNoContentCREATE/UPDATE
Body TextLonger body copytextNoContentCREATE/UPDATE
Image URLOptional illustrationstring (URL)NoContentCREATE/UPDATE
Background ColorOverride backgroundstring (hex)NoContentCREATE/UPDATE
ThemeVisual themelight/dark/brandNolightContentCREATE/UPDATE
Primary CTA LabelPrimary button textstringYesCall-to-ActionCREATE/UPDATE
Primary CTA URLPrimary button destination (UTM auto-appended)string (URL)YesCall-to-ActionCREATE/UPDATE
Secondary CTA LabelSecondary button textstringNoCall-to-ActionCREATE/UPDATE
Secondary CTA URLSecondary destination / dismissstring (URL)NoCall-to-ActionCREATE/UPDATE
Field / OptionWhat it doesTypeMandatory?DefaultWhere configuredRole
Linked CouponShows code + copy button; auto-applies at checkout. Server hides invalid coupons.couponIdNoEntity LinksCREATE/UPDATE
Linked PlanPlan context for subscribe goalsplanIdNoEntity LinksCREATE/UPDATE
Linked CampaignCTA targets a campaigncampaignIdNoEntity LinksCREATE/UPDATE
Linked EventCTA targets an eventeventIdNoEntity LinksCREATE/UPDATE
Linked ProductCTA targets a shop productproductIdNoEntity LinksCREATE/UPDATE
NewsletterNewsletter edition referencenewsletterSlugNoEntity LinksCREATE/UPDATE

Targeting (all optional — empty = match all)

Field / OptionWhat it doesTypeMandatory?DefaultWhere configuredRole
AudiencesReader auth/subscription segmentsstring[]No[] (all)TargetingCREATE/UPDATE
Page TypesRestrict to page typesstring[]No[] (all)TargetingCREATE/UPDATE
Device TypesRestrict to devicesstring[]No[] (all)TargetingCREATE/UPDATE
Content Types / Sections / TagsArticle-level targetingstring[]No[] (all)Targeting (advanced)CREATE/UPDATE
Include / Exclude URLsPath-pattern allow/deny (exclude wins)string[]No[]Targeting (advanced)CREATE/UPDATE
UTM Source / Medium / CampaignChannel targetingstring[]No[] (all)Targeting (advanced)CREATE/UPDATE

Trigger, frequency & scheduling

Field / OptionWhat it doesTypeMandatory?DefaultWhere configuredRole
TriggerWhen the surface firesenum (7)Nopage_loadTriggerCREATE/UPDATE
Trigger Delay SecondsFor time_on_pageintegerNo0TriggerCREATE/UPDATE
Trigger Scroll PercentFor scroll_depthintegerNo0TriggerCREATE/UPDATE
Trigger Page ViewsFor page_view_countintegerNo0TriggerCREATE/UPDATE
Frequency LimitMax displays per windowintegerNo1Frequency & SchedulingCREATE/UPDATE
Frequency UnitWindowenum (4)NosessionFrequency & SchedulingCREATE/UPDATE
Suppress After DismissDays hidden after dismissintegerNo1Frequency & SchedulingCREATE/UPDATE
Auto-Dismiss SecondsAuto-close after N secondsinteger?NonullFrequency & SchedulingCREATE/UPDATE
Suppress After ConversionHide after converting on this surfacebooleanNotrueFrequency & SchedulingCREATE/UPDATE
Suppress If ConvertedHide if goal already metbooleanNotrueFrequency & SchedulingCREATE/UPDATE
StatusLifecycle stateenum (6)NodraftStatusUPDATE
Starts AtScheduled activation timedatetime?NoFrequency & SchedulingCREATE/UPDATE
Ends AtAuto-expiration timedatetime?NoFrequency & SchedulingCREATE/UPDATE

Status lifecycle

StatusVisible to readers?Notes
draftNoDefault on create / duplicate
scheduledNoHas a future startsAt; cron auto-activates
activeYesLive to eligible readers
pausedNoManually disabled
expiredNoPast endsAt; cron auto-expires
archivedNoTerminal; preserved for analytics

Transitions: draft → active/scheduled/archived · scheduled → active/draft/archived · active → paused/archived · paused → active/archived · expired → archived · archived → (none).

Conversion surface preview
Preview a surface's appearance before activating. Authored surfaces are also viewable under the Reader Portal /preview/conversion-surfaces gallery.

Coupon integration

When a coupon is linked, the eligible API validates it server-side and only ships it if it is active (isActive = true), not expired (validTo >= now or null), and not exhausted (usedCount < maxUses when maxUses is set). If any check fails the surface still renders, but without the coupon. The reader sees the code with a Copy Code button (fires a coupon_copied event), and for checkout/subscribe CTAs the code is appended as ?coupon=CODE for auto-apply.

UTM attribution

Internal/same-origin CTA URLs are auto-enriched: utm_source=conversion_surface, utm_medium=promo, utm_campaign=cs_{goal}_{surfaceId}. Existing UTM params on the URL are never overwritten, so downstream conversions attribute back to the originating surface.

RBAC

PermissionGrants
MARKETING_CONVERSION_SURFACES_READView list, details, and stats
MARKETING_CONVERSION_SURFACES_CREATECreate surfaces, duplicate, seed templates
MARKETING_CONVERSION_SURFACES_UPDATEEdit surfaces, change status
MARKETING_CONVERSION_SURFACES_DELETEArchive surfaces

The public Reader Portal endpoints (/api/public/conversion-surfaces/eligible and /event) require no admin auth — they optionally detect a reader token for audience/suppression accuracy.


Analytics & Tracking

Every interaction is written to the conversion_events table (ConversionEventType: impression, click, dismiss, conversion, coupon_copied) with reader context (readerId?, sessionId), page context (pageUrl, pageType, deviceType), UTM context, and metadata. Internal events are recorded regardless of analytics consent; GA4 events fire only with consent.

The surface row keeps denormalized counters incremented in the same write path: impressionCount, clickCount, dismissCount, conversionCount. Note: coupon_copied is intentionally not mapped to a counter — it is captured as an event row only.

Derived metrics in the stats API: CTR (clickCount/impressionCount), dismiss rate, conversion rate, and engagement rate ((clicks + conversions)/impressions). The admin stats view breaks these down by device, page type, and UTM source, with a daily/weekly/monthly time series.

GA4 events mirror the funnel: conversion_surface_impression, conversion_surface_click, conversion_surface_dismiss, conversion_surface_conversion, coupon_copied, plus goal-specific newsletter_signup and registration_start.


Dependencies & Impact

Conversion Surfaces sit at the intersection of several modules.

ModuleRelationship
MarketingSurfaces are a Marketing & Outreach capability; coupons, campaigns, and UTM attribution originate here.
Subscriptionssubscribe_paid / visit_pricing surfaces drive subscription conversions; linked plans and coupons feed the checkout.
Paywall & AccessSubscription surfaces complement paywall gates; audience targeting (registered, subscriber) depends on access state. The paywall overlay itself is a code-driven conversion surface (see Adjacent Conversion Entry Points).
AnalyticsImpression/conversion events feed both internal reporting and GA4, enabling cross-funnel attribution.

Operational dependencies: the lifecycle cron must run for scheduled activation/expiration; GA4 + consent gating governs external analytics; linked coupons/plans/campaigns/events/products must stay valid or the eligible API silently drops them; the Reader Portal renders surfaces globally except on suppressed pages (/login, /register, /account/*, /subscribe/*, maintenance, preview).

Impact when changed: activating a high-priority surface can suppress lower-priority ones (one overlay per page load); editing targeting changes who sees it on the next eligibility fetch (not retroactively); archiving preserves history but removes the surface from readers immediately.


Known Limitations

LimitationWorkaround
No native A/B testingTwo surfaces, same targeting, different priority
No geographic targetingUse UTM targeting
Cron lag up to one interval (~15 min) on schedule boundariesManually flip status for exact-time go-live
Exit intent is desktop-onlyMobile falls back to scroll/time
Anonymous frequency is client-side (localStorage)Server tracking only for authenticated readers
One overlay surface per page loadUse priority to pick the winner
coupon_copied has no denormalized counterQuery conversion_events directly

FAQ