PageSourceSearch

https://kansei-link.com/js/paywall.js

js kansei-link.com collected 2026-09-28 06:34:14 UTC 9,152 bytes, 191 lines download raw bytes

1/**
2 * KanseiLink Paywall — server-enforced premium content loader.
3 *
4 * Articles contain EMPTY placeholders:
5 *   <div data-tier="pro" data-premium-id="insights/some-article"></div>
6 * The premium HTML is NOT in the static page (the repo and Pages are public);
7 * it lives in the KanseiLink API DB and is fetched here for subscribers:
8 *   GET /api/premium?article=<id>&email=<email>&token=<access-token>
9 *
10 * Credentials (localStorage):
11 *   kl_email — subscriber email
12 *   kl_token — per-email access token, issued ONLY by:
13 *     - /api/access-token after Stripe checkout (subscription/success.html), or
14 *     - magic-link sign-in (subscription/login.html)
15 *
16 * Adding new gated articles: wrap the section with data-tier, then run
17 *   node scripts/extract-premium-sections.mjs && node scripts/upload-premium-content.mjs
18 */
19(function () {
20  "use strict";
21
22  var API_BASE = window.KANSEI_API_BASE || "https://kansei-link-mcp-production.up.railway.app";
23  var STORAGE_KEY = "kl_email";
24  var TOKEN_KEY = "kl_token";
25
26  // Detect language from <html lang="...">
27  var lang = (document.documentElement.lang || "ja").startsWith("en") ? "en" : "ja";
28  var LOGIN_PATH = lang === "en" ? "/en/subscription/login.html" : "/subscription/login.html";
29  var PRICING_PATH = lang === "en" ? "/en/pricing.html" : "/pricing.html";
30
31  var TIER_LABELS = {
32    ja: {
33      pro: { badge: "Pro", title: "Pro プランで全文を読む", desc: "Agent Voice詳細、レシピ成功率、gotchas、GPT/Claude/Gemini比較が見放題。", cta: "Pro プランに登録($19/月)", login: "登録済みの方はログイン" },
34      team: { badge: "Team", title: "Team プランで詳細レポートを見る", desc: "指定サービスの詳細レポート、競合比較、AXR推移、Agent Voice生データにアクセス。", cta: "Team プランに登録($149/月)", login: "登録済みの方はログイン" },
35      upgrade: "プランのアップグレードが必要です",
36      error: "コンテンツを読み込めませんでした。再読み込みしてください。"
37    },
38    en: {
39      pro: { badge: "Pro", title: "Read the full article with Pro", desc: "Unlock Agent Voice details, recipe success rates, gotchas, and GPT/Claude/Gemini comparison.", cta: "Subscribe to Pro ($19/mo)", login: "Already subscribed? Sign in" },
40      team: { badge: "Team", title: "Access detailed reports with Team", desc: "Per-service detailed reports, competitive analysis, AXR trends, and raw Agent Voice data.", cta: "Subscribe to Team ($149/mo)", login: "Already subscribed? Sign in" },
41      upgrade: "A plan upgrade is required for this section",
42      error: "Could not load this section. Please reload the page."
43    }
44  };
45
46  function getEmail() {
47    return localStorage.getItem(STORAGE_KEY) || "";
48  }
49  function getToken() {
50    return localStorage.getItem(TOKEN_KEY) || "";
51  }
52
53  // ─── CTA card (rendered INSIDE the empty placeholder) ───
54  function renderCTA(section, requiredTier, opts) {
55    var labels = TIER_LABELS[lang][requiredTier] || TIER_LABELS[lang].pro;
56    var note = (opts && opts.note) || "";
57    section.innerHTML =
58      '<div class="kl-paywall-cta" style="text-align:center;padding:36px 24px;margin:24px 0;background:linear-gradient(135deg,#F4F5FD 0%,#EEF2FF 100%);border:1px solid #E0E7FF;border-radius:16px;">' +
59        '<span style="display:inline-block;padding:4px 12px;background:var(--teal,#00bfa5);color:#fff;border-radius:12px;font-size:12px;font-weight:700;margin-bottom:12px;">' + labels.badge + '</span>' +
60        '<h3 style="margin:0 0 8px;font-size:20px;">' + labels.title + '</h3>' +
61        '<p style="color:#666;margin:0 auto 20px;font-size:15px;line-height:1.6;max-width:480px;">' + labels.desc + '</p>' +
62        (note ? '<p style="color:#b45309;margin:0 0 16px;font-size:13.5px;">' + note + '</p>' : '') +
63        '<button class="kl-checkout-btn" data-plan="' + requiredTier + '" style="display:inline-block;padding:12px 32px;background:var(--teal,#00bfa5);color:#fff;border:none;border-radius:8px;font-size:16px;font-weight:700;cursor:pointer;margin-bottom:12px;">' + labels.cta + '</button>' +
64        '<br><a href="' + LOGIN_PATH + '" class="kl-login-link" style="color:var(--teal,#00bfa5);font-size:14px;text-decoration:none;">' + labels.login + '</a>' +
65      '</div>';
66  }
67
68  // ─── Premium fetch ───
69  function loadPremium(section, articleId, email, token) {
70    var requiredTier = section.getAttribute("data-tier") || "pro";
71    var url = API_BASE + "/api/premium?article=" + encodeURIComponent(articleId) +
72              "&email=" + encodeURIComponent(email) +
73              "&token=" + encodeURIComponent(token);
74    var xhr = new XMLHttpRequest();
75    xhr.open("GET", url);
76    xhr.onload = function () {
77      if (xhr.status === 200) {
78        try {
79          var data = JSON.parse(xhr.responseText);
80          section.innerHTML = data.html;
81          section.setAttribute("data-premium-loaded", "1");
82          return;
83        } catch (e) { /* fall through to CTA */ }
84        renderCTA(section, requiredTier, { note: TIER_LABELS[lang].error });
85      } else if (xhr.status === 403) {
86        // Signed in, but the plan is too low for this section.
87        renderCTA(section, requiredTier, { note: TIER_LABELS[lang].upgrade });
88      } else if (xhr.status === 401) {
89        // Token missing/stale (e.g. secret rotation) — sign in again.
90        renderCTA(section, requiredTier);
91      } else {
92        renderCTA(section, requiredTier, { note: TIER_LABELS[lang].error });
93      }
94    };
95    xhr.onerror = function () {
96      renderCTA(section, requiredTier, { note: TIER_LABELS[lang].error });
97    };
98    xhr.send();
99  }
100
101  // ─── Checkout (price IDs come from /api/config at click time) ───
102  function startCheckout(plan) {
103    var xhr = new XMLHttpRequest();
104    xhr.open("GET", API_BASE + "/api/config");
105    xhr.onload = function () {
106      var priceId = "";
107      if (xhr.status === 200) {
108        try {
109          var prices = (JSON.parse(xhr.responseText).prices || {});
110          priceId = plan === "team" ? prices.team : prices.proMonthly;
111        } catch (e) { /* ignore */ }
112      }
113      if (!priceId) {
114        window.location.href = PRICING_PATH;
115        return;
116      }
117      var body = { priceId: priceId };
118      var email = getEmail();
119      if (email) body.email = email;
120      var checkout = new XMLHttpRequest();
121      checkout.open("POST", API_BASE + "/api/checkout");
122      checkout.setRequestHeader("Content-Type", "application/json");
123      checkout.onload = function () {
124        if (checkout.status === 200) {
125          try {
126            var data = JSON.parse(checkout.responseText);
127            if (data.url) { window.location.href = data.url; return; }
128          } catch (e) { /* ignore */ }
129        }
130        window.location.href = PRICING_PATH;
131      };
132      checkout.onerror = function () { window.location.href = PRICING_PATH; };
133      checkout.send(JSON.stringify(body));
134    };
135    xhr.onerror = function () { window.location.href = PRICING_PATH; };
136    xhr.send();
137  }
138
139  // ─── Main ───
140  function init() {
141    var sections = document.querySelectorAll("[data-premium-id]");
142    if (sections.length) {
143      var email = getEmail();
144      var token = getToken();
145      for (var i = 0; i < sections.length; i++) {
146        var section = sections[i];
147        var articleId = section.getAttribute("data-premium-id");
148        if (email && token) {
149          loadPremium(section, articleId, email, token);
150        } else {
151          renderCTA(section, section.getAttribute("data-tier") || "pro");
152        }
153      }
154    }
155
156    // Event delegation for CTA checkout buttons. Acts ONLY on buttons with
157    // data-plan (our CTA cards) or data-price (legacy explicit price ID) —
158    // pricing.html has its own .kl-checkout-btn handler using
159    // data-price-monthly/annual, which must not double-fire here.
160    document.addEventListener("click", function (e) {
161      var btn = e.target.closest(".kl-checkout-btn");
162      if (btn && (btn.hasAttribute("data-plan") || btn.getAttribute("data-price"))) {
163        e.preventDefault();
164        var explicitPrice = btn.getAttribute("data-price");
165        if (explicitPrice) {
166          // Legacy path: a concrete price ID was provided on the button.
167          var body = { priceId: explicitPrice };
168          var email = getEmail();
169          if (email) body.email = email;
170          var xhr = new XMLHttpRequest();
171          xhr.open("POST", API_BASE + "/api/checkout");
172          xhr.setRequestHeader("Content-Type", "application/json");
173          xhr.onload = function () {
174            if (xhr.status === 200) {
175              try { var d = JSON.parse(xhr.responseText); if (d.url) window.location.href = d.url; } catch (err) { /* ignore */ }
176            }
177          };
178          xhr.send(JSON.stringify(body));
179        } else {
180          startCheckout(btn.getAttribute("data-plan") || "pro");
181        }
182      }
183    });
184  }
185
186  if (document.readyState === "loading") {
187    document.addEventListener("DOMContentLoaded", init);
188  } else {
189    init();
190  }
191})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.