Universal Subscription Modal — Architecture Decision

Problem

Current subscription system is fragmented:

Design Goals

  1. One modal, any page — single subscribe-modal.js with no page-specific logic
  2. Path-matched config — each path or glob has its own incentive + post-subscribe route
  3. Incentive layer — tell users what they get, not just “subscribe”
  4. Broadcast on complete — rich event any module can listen to
  5. Route instruction pattern — modal doesn’t know about gates; gates listen for events
  6. Backward compatible — existing gates keep working; modal is additive

Architecture

Layer 1 — Path Config (build-time, _data/subscribe_paths.yml)

paths:
  - match: "/bookprogrammer/**"
    mode: overlay
    autoShow: true
    delay: 3000
    incentive:
      type: book
      bookId: programming-zero-hero
      label_ar: "اشترك للحصول على الكتاب كاملاً"
      label_en: "Subscribe to unlock the full book"
      image: /bookprogrammer/cover.webp
    onSubscribe:
      route: book-gate
      action: unlock
      bookId: programming-zero-hero

  - match: "/pybook/**"
    mode: overlay
    autoShow: true
    delay: 0           # show immediately on PDF click, not on page load
    trigger: pdf-click  # only show when PDF button clicked, not on timer
    incentive:
      type: book
      bookId: python-book
      label_ar: "اشترك للتحميل"
    onSubscribe:
      route: book-gate
      action: unlock
      bookId: python-book

  - match: "/techquiz/**"
    mode: sheet
    autoShow: false     # manual trigger only (e.g. after score)
    incentive:
      type: app
      appId: techquiz
      label_ar: "اشترك لحفظ نتائجك"
    onSubscribe:
      route: app-gate
      action: unlock
      appId: techquiz

  - match: "/**"       # global fallback
    mode: sheet
    autoShow: true
    delay: 8000
    incentive:
      type: newsletter
      label_ar: "اشترك لتصلك المقالات الجديدة"
    onSubscribe:
      route: push
      action: subscribed
    secondaryOffer:
      label_ar: "هل تريد أيضاً الوصول إلى الكتب؟"
      route: library

Jekyll inlines the matched config at build time into each layout’s AppCoreConfig.subscribeModal. Path matching at build time = zero JS overhead for matching.

Alternative (runtime matching): Store paths in JS, match at runtime with glob. Simpler to update without rebuild, but adds ~2KB of matching logic and runs on every page load. Prefer build-time for performance; runtime for dynamic paths (user-specific).


Layer 2 — The Modal (assets/js/subscribe-modal.js)

Single responsibility: show UI, collect subscription, broadcast event.

(function () {
  var config = window.AppCoreConfig?.subscribeModal;
  if (!config?.enabled) return;

  // Display modes
  var MODES = { overlay: renderOverlay, sheet: renderSheet };

  function show(triggerSource) {
    var mode = MODES[config.mode] || MODES.sheet;
    mode(config.incentive, triggerSource);
  }

  function renderSheet(incentive) { /* bottom panel, swipe-dismiss */ }
  function renderOverlay(incentive) { /* full backdrop, centered card */ }

  function onSubscribeComplete(type, scope) {
    // Broadcast — this is the only thing subscribe-modal.js cares about after subscribe
    window.dispatchEvent(new CustomEvent('subscribeModal:complete', {
      bubbles: true,
      detail: {
        type:      type,            // 'push' | 'email'
        scope:     scope,           // 'page' | 'site'
        path:      location.pathname,
        site:      location.hostname,
        incentive: config.incentive, // { type, bookId, appId, ... }
        route:     config.onSubscribe // { route, action, bookId, ... }
      }
    }));
  }

  // Triggers
  if (config.autoShow) {
    setTimeout(function () {
      if (!alreadySubscribed()) show('timer');
    }, config.delay || 5000);
  }

  // Expose for manual trigger: SubscribeModal.show('pdf-click')
  window.SubscribeModal = { show: show };
})();

What subscribe-modal.js does NOT do:


Layer 3 — The Router (assets/js/subscribe-router.js or inline in app-core.js)

Listens for subscribeModal:complete, reads the route instruction, calls the right gate.

window.addEventListener('subscribeModal:complete', function (e) {
  var d = e.detail;
  var r = d.route; // { route, action, bookId, appId }

  switch (r.route) {
    case 'book-gate':
      if (window.BookGate) BookGate.unlock(r.bookId);
      break;
    case 'email-gate':
      if (window.EmailGate) EmailGate.unlock();
      break;
    case 'app-gate':
      if (window.AppGate) AppGate.unlock(r.appId);
      break;
    case 'library':
      window.location.href = '/library/';
      break;
    case 'push':
      // already subscribed via push — nothing extra to do
      break;
  }
});

Adding a new gate = one new case in this switch. No changes to subscribe-modal.js.

The router lives in app-core.js (loads on every page) or as a separate subscribe-router.js loaded whenever subscribeModal is enabled.


Layer 4 — Gates Listen Too (optional hardening)

Each gate can also listen for the event, independent of the router. This creates two unlock paths:

  1. Router hears event → calls BookGate.unlock(bookId) directly
  2. BookGate itself hears event → checks if detail.route.bookId === this.bookId → self-unlocks

The second path is the resilient one: even if the router isn’t loaded on a page, the gate can unlock itself. Belt and suspenders.

// Inside book-gate.js or bookpost.html inline script
window.addEventListener('subscribeModal:complete', function (e) {
  var d = e.detail;
  if (d.route?.route === 'book-gate' && d.route?.bookId === MY_BOOK_ID) {
    unlockChapter();
  }
});

Layer 5 — Secondary Offer (site-wide subscribe after page-specific)

After a successful page-specific subscribe, the modal can offer a secondary action:

function showSecondaryOffer(primaryComplete) {
  if (!config.secondaryOffer) return;
  // Show a smaller follow-on card: "هل تريد أيضاً...?"
  // User can dismiss or accept
  // On accept: fire a second subscribeModal:complete with scope:'site'
}

This is always optional (secondaryOffer key in config). No secondary offer = nothing shown after subscribe.


Config Structure in AppCoreConfig

window.AppCoreConfig = {
  subscribeModal: {
    enabled: true,
    mode: 'sheet',          // 'overlay' | 'sheet'
    autoShow: true,
    delay: 5000,
    trigger: 'timer',       // 'timer' | 'manual' | 'pdf-click' | 'scroll-50'
    incentive: {
      type: 'newsletter',   // 'newsletter' | 'book' | 'app' | 'course' | null
      bookId: null,
      appId: null,
      label_ar: 'اشترك للمتابعة',
      label_en: 'Subscribe to follow',
      image: null           // optional cover image shown in modal
    },
    onSubscribe: {
      route: 'push',        // 'push' | 'book-gate' | 'email-gate' | 'app-gate' | 'library'
      action: 'subscribed',
      bookId: null,
      appId: null
    },
    secondaryOffer: null    // or { label_ar, route }
  }
};

Implementation Order

  1. _data/subscribe_paths.yml — define path configs (no code yet)
  2. Jekyll plugin or include — match current page path, inject matched config into AppCoreConfig at build time
  3. assets/js/subscribe-modal.js — UI only: sheet + overlay, incentive display, subscribe call, event broadcast
  4. Router in app-core.jssubscribeModal:complete listener, route → gate dispatch
  5. Disable existing modalsenabled: false in each layout
  6. Enable on main + book pages — per subscribe_paths.yml
  7. Add secondary offer — after page-specific subscribe works end-to-end

Open Questions