Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions worker/src/articles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3754,6 +3754,8 @@ ${uiNav(origin)}

${relatedHtml(a)}

<p style="text-align:center;margin:18px 0"><a href="/guide" style="display:inline-block;background:#ff9e2c;color:#1a1206;font-weight:700;padding:13px 20px;border-radius:11px;text-decoration:none">📘 The Drop-Day Playbook — the restock-day checklist ($2.99)</a></p>

<footer>
<a href="/guides">All guides</a> · <a href="/drops">Restock guides</a> · <a href="/tools">Free tools</a> · <a href="/">wmcp.sh</a>
</footer>
Expand Down
129 changes: 120 additions & 9 deletions worker/src/digital.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,17 @@ type Env = {
KEYS: KVNamespace;
STRIPE_SECRET_KEY?: string;
STRIPE_PRICE_GUIDE?: string;
STRIPE_PRICE_CALCPRO?: string;
};

type ProductId = "guide";
type ProductId = "guide" | "calcpro";

interface ProductDef {
priceEnv: keyof Env;
price: string; // display only
title: string;
tagline: string; // sales-page sub-headline
bullets: string[]; // "what's inside"
render: () => string; // the gated content (HTML body)
}

Expand All @@ -34,8 +37,30 @@ const PRODUCTS: Record<ProductId, ProductDef> = {
priceEnv: "STRIPE_PRICE_GUIDE",
price: "$2.99",
title: "The Pokémon Drop-Day Playbook",
tagline: "Stop paying resellers. The exact restock-day checklist to land Pokémon sealed product at retail — on your phone, no app, no subscription.",
bullets: [
"The 90-second pre-drop checklist",
"Store-by-store setup (Pokémon Center, Walmart, Target, Best Buy)",
"Retail-vs-resale margin math (skip the markup)",
"Is-it-worth-grading decision framework",
"Restock timing + scalper-trap avoidance",
],
render: () => GUIDE_HTML,
},
calcpro: {
priceEnv: "STRIPE_PRICE_CALCPRO",
price: "$4.99",
title: "Pro Portfolio Calculator",
tagline: "The free calculator does one item. This does your whole collection — track every sealed box and card, see your total savings and resale profit at a glance. Saves on your device, no account.",
bullets: [
"Add unlimited items — your whole binder/haul",
"Running totals: spend, savings, resale profit, blended margin",
"Saves to your device automatically (no login)",
"Print or screenshot a clean summary",
"Works on mobile, reopen anytime from your private link",
],
render: () => CALCPRO_HTML,
},
};

const esc = (s: string) =>
Expand Down Expand Up @@ -117,6 +142,96 @@ If that number is close to retail, just buy retail and skip the markup. If a sea
<div class="card"><b>That's the whole method.</b> Save this page or print it (your browser's Share → Print). You can reopen it anytime from the link below.</div>
`;

// Calculator Pro — a self-contained multi-item portfolio tool. Lives entirely on
// the unlock page (the free /tools calculator is untouched). State persists in
// localStorage, so it works with no account and survives reloads.
const CALCPRO_HTML = `
<style>
.cp-in{width:100%;background:#11111a;border:1px solid #34343f;border-radius:9px;color:#ececf1;padding:11px 12px;font-size:1rem}
.cp-grid{display:grid;grid-template-columns:1fr 1fr 1fr .7fr;gap:8px;margin:10px 0}
.cp-grid label{font-size:.78rem;color:#9a9aae;display:block;margin:0 0 3px}
.cp-add{margin-top:10px}
table.cp{width:100%;border-collapse:collapse;font-size:.92rem;margin-top:6px}
table.cp th,table.cp td{text-align:right;padding:8px 6px;border-bottom:1px solid #26262f}
table.cp th:first-child,table.cp td:first-child{text-align:left}
.cp-x{background:none;border:0;color:#ff7a7a;cursor:pointer;font-size:1rem}
.cp-tot{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px dashed #2a2a36}
.cp-tot b{font-size:1.15rem}
.pos{color:#7fdca0}.neg{color:#ff7a7a}
.cp-row{display:flex;gap:10px;margin-top:14px}
.cp-row button{flex:1;background:#22222c;color:#ececf1;border:1px solid #34343f;border-radius:10px;padding:11px;cursor:pointer}
@media(max-width:520px){.cp-grid{grid-template-columns:1fr 1fr}}
</style>
<h1>Pro Portfolio Calculator</h1>
<p class="mut">Add every sealed box and card you're tracking. It keeps a running tally of what you'd spend at retail, what you save versus resale, and your profit if you flip — saved right on this device.</p>

<div class="card">
<div class="cp-grid">
<div style="grid-column:1/-1"><label>Item (optional)</label><input class="cp-in" id="cp-name" placeholder="e.g. Prismatic Evolutions ETB"></div>
<div><label>Retail $</label><input class="cp-in" id="cp-retail" type="number" inputmode="decimal" min="0" step="0.01"></div>
<div><label>Resale $</label><input class="cp-in" id="cp-resale" type="number" inputmode="decimal" min="0" step="0.01"></div>
<div><label>Qty</label><input class="cp-in" id="cp-qty" type="number" inputmode="numeric" min="1" step="1" value="1"></div>
<div style="align-self:end"><button class="buy cp-add" id="cp-addbtn" style="padding:11px">Add</button></div>
</div>
</div>

<div class="card" id="cp-listcard" style="display:none">
<table class="cp"><thead><tr><th>Item</th><th>Qty</th><th>Spend</th><th>Save/Profit</th><th></th></tr></thead><tbody id="cp-rows"></tbody></table>
</div>

<div class="card" id="cp-totcard" style="display:none">
<div class="cp-tot"><span>Items</span><b id="cp-count">0</b></div>
<div class="cp-tot"><span>Total retail spend</span><b id="cp-spend">$0.00</b></div>
<div class="cp-tot"><span>Total saved (collecting)</span><b class="pos" id="cp-save">$0.00</b></div>
<div class="cp-tot"><span>Total profit (reselling)</span><b class="pos" id="cp-profit">$0.00</b></div>
<div class="cp-tot" style="border:0"><span>Blended margin</span><b id="cp-margin">—</b></div>
<div class="cp-row noprint"><button id="cp-print">Print / screenshot</button><button id="cp-clear">Clear all</button></div>
</div>
<p class="mut" id="cp-empty">No items yet — add your first above. Savings/profit = (resale − retail) × qty. Resale prices move, so update them as you go.</p>

<script>
(function(){
var KEY='calcpro_items', items=[];
try{ items=JSON.parse(localStorage.getItem(KEY)||'[]')||[]; }catch(e){ items=[]; }
var $=function(id){return document.getElementById(id);};
var money=function(n){return '$'+(Math.round(n*100)/100).toFixed(2);};
function save(){ try{ localStorage.setItem(KEY, JSON.stringify(items)); }catch(e){} }
function render(){
var rows='', spend=0, save_=0;
items.forEach(function(it,i){
var s=(it.retail*it.qty), sv=((it.resale-it.retail)*it.qty);
spend+=s; save_+=sv;
rows+='<tr><td>'+(it.name?String(it.name).replace(/[<>&]/g,''):'Item')+'</td><td>'+it.qty+'</td><td>'+money(s)+'</td><td class="'+(sv>=0?'pos':'neg')+'">'+money(sv)+'</td><td><button class="cp-x" data-i="'+i+'">✕</button></td></tr>';
});
$('cp-rows').innerHTML=rows;
$('cp-count').textContent=items.length;
$('cp-spend').textContent=money(spend);
$('cp-save').textContent=money(save_);
$('cp-profit').textContent=money(save_);
$('cp-margin').textContent= spend>0 ? (Math.round((save_/spend)*1000)/10)+'%' : '—';
var has=items.length>0;
$('cp-listcard').style.display=has?'block':'none';
$('cp-totcard').style.display=has?'block':'none';
$('cp-empty').style.display=has?'none':'block';
}
$('cp-addbtn').addEventListener('click',function(){
var r=parseFloat($('cp-retail').value), p=parseFloat($('cp-resale').value), q=parseInt($('cp-qty').value,10)||1;
if(!(r>0)||!(p>=0)){ $('cp-retail').focus(); return; }
items.push({name:$('cp-name').value.slice(0,40), retail:r, resale:p, qty:Math.max(1,q)});
save(); render();
$('cp-name').value=''; $('cp-retail').value=''; $('cp-resale').value=''; $('cp-qty').value='1'; $('cp-name').focus();
});
$('cp-rows').addEventListener('click',function(e){
var b=e.target.closest('.cp-x'); if(!b) return;
items.splice(parseInt(b.dataset.i,10),1); save(); render();
});
$('cp-clear').addEventListener('click',function(){ if(confirm('Clear your whole list?')){ items=[]; save(); render(); } });
$('cp-print').addEventListener('click',function(){ window.print(); });
render();
})();
</script>
`;

// ------------------------------- handlers -------------------------------

// POST /api/v1/<product>/checkout — no email pre-form (Stripe collects it);
Expand Down Expand Up @@ -179,7 +294,7 @@ export async function digitalRead(c: Context<{ Bindings: Env }>, product: Produc
const raw = t ? await c.env.KEYS.get(`dlg:${t}`) : null;
let ok = false;
try { ok = !!raw && JSON.parse(raw).product === product; } catch {}
if (!ok) return c.html(shell(def.title, `<h1>${esc(def.title)}</h1><p class="mut">That access link isn't valid. <a href="/${product}">Get the playbook</a></p>`), 403);
if (!ok) return c.html(shell(def.title, `<h1>${esc(def.title)}</h1><p class="mut">That access link isn't valid. <a href="/${product}">Get ${esc(def.title)}</a></p>`), 403);
return c.html(renderUnlocked(def, product, t));
}

Expand All @@ -194,16 +309,12 @@ export function digitalSalesHtml(origin: string, product: ProductId, canceled: b
if (!def) return shell("Not found", "<h1>Not found</h1>");
const body = `
<h1>${esc(def.title)}</h1>
<p class="mut">Stop paying resellers. The exact restock-day checklist to land Pokémon sealed product at retail — on your phone, no app, no subscription.</p>
${canceled ? `<div class="card noprint">No worries — checkout was canceled. The playbook's still here when you want it.</div>` : ""}
<p class="mut">${esc(def.tagline)}</p>
${canceled ? `<div class="card noprint">No worries — checkout was canceled. It's still here when you want it.</div>` : ""}
<div class="card">
<h2 style="margin-top:0">What's inside</h2>
<ul class="ck">
<li>The 90-second pre-drop checklist</li>
<li>Store-by-store setup (Pokémon Center, Walmart, Target, Best Buy)</li>
<li>Retail-vs-resale margin math (skip the markup)</li>
<li>Is-it-worth-grading decision framework</li>
<li>Restock timing + scalper-trap avoidance</li>
${def.bullets.map((b) => `<li>${esc(b)}</li>`).join("\n")}
</ul>
</div>
<div class="card" style="text-align:center">
Expand Down
4 changes: 4 additions & 0 deletions worker/src/drops_seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1447,6 +1447,10 @@ ${localizable ? hreflangTags(origin, p.slug) + "\n" : ""}<meta property="og:titl

${conversionBlock(f.topic, ui)}

<section style="text-align:center;margin:10px 0 4px">
<a href="${origin}/guide" style="display:inline-block;background:var(--accent2,#ff9e2c);color:#1a1206;font-weight:700;padding:13px 20px;border-radius:11px;text-decoration:none">📘 The Drop-Day Playbook — land restocks at retail ($2.99)</a>
</section>

<footer>
<p class="related">${esc(ui.more)} ${relatedFor(p, origin, useLang)}</p>
${localizable ? langSwitcher(origin, p.slug, useLang) : ""}
Expand Down
17 changes: 17 additions & 0 deletions worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,23 @@ app.get("/guide/read", async (c) => {
const { digitalRead } = await import("./digital");
return digitalRead(c as any, "guide");
});
// Calculator Pro ($4.99) — second impulse product on the same engine.
app.get("/calcpro", async (c) => {
const { digitalSalesHtml } = await import("./digital");
return c.html(digitalSalesHtml(new URL(c.req.url).origin, "calcpro", c.req.query("canceled") === "1"));
});
app.post("/api/v1/calcpro/checkout", async (c) => {
const { createDigitalCheckout } = await import("./digital");
return createDigitalCheckout(c as any, "calcpro");
});
app.get("/calcpro/unlock", async (c) => {
const { digitalUnlock } = await import("./digital");
return digitalUnlock(c as any, "calcpro");
});
app.get("/calcpro/read", async (c) => {
const { digitalRead } = await import("./digital");
return digitalRead(c as any, "calcpro");
});

app.get("/api/v1/health", (c) =>
c.json({ ok: true, version: "0.1.0", env: c.env.ENVIRONMENT })
Expand Down
9 changes: 9 additions & 0 deletions worker/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,15 @@ ${alternates(origin, "calc")}
</div>
</section>

<section class="cta" style="margin-top:18px">
<h2>Do your whole collection — Pro Portfolio Calculator</h2>
<p>The free tool above does one item. Pro tallies every sealed box and card you own — total spend, savings, and resale profit at a glance, saved on your device. One-time, no app.</p>
<div class="row">
<a class="btn btn-primary" href="${origin}/calcpro">⚡ Unlock Pro — $4.99</a>
<a class="btn btn-ghost" href="${origin}/guide">📘 Drop-Day Playbook — $2.99</a>
</div>
</section>

<div class="cta">
<h2>${esc(c.ctaH)}</h2>
<p>${esc(c.ctaP)}</p>
Expand Down
29 changes: 29 additions & 0 deletions worker/test/digital.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,33 @@ describe("digital impulse products", () => {
expect(html).toContain("Get instant access");
expect(html).toContain("no signup");
});

it("calcpro checkout is fail-closed without its price (503)", async () => {
const c = makeCtx({ env: envMock({ STRIPE_SECRET_KEY: "sk_test" }), method: "POST", body: {}, url: "https://wmcp.sh/api/v1/calcpro/checkout" });
const r: any = await createDigitalCheckout(c, "calcpro");
expect(r.status).toBe(503);
expect(r.body.error).toBe("calcpro_not_configured");
});

it("calcpro sales page renders at \$4.99 with its own copy", () => {
const html = digitalSalesHtml("https://wmcp.sh", "calcpro", false);
expect(html).toContain("$4.99");
expect(html).toContain('data-product="calcpro"');
expect(html).toContain("Portfolio");
});

it("calcpro unlock content serves the portfolio tool for a valid token", async () => {
const env = envMock();
await env.KEYS.put("dlg:cptoken", JSON.stringify({ product: "calcpro", ts: 1 }));
const c = makeCtx({ env, method: "GET", query: { t: "cptoken" }, url: "https://wmcp.sh/calcpro/read?t=cptoken" });
const r: any = await digitalRead(c, "calcpro");
expect(r.status).toBe(200);
expect(String(r.body)).toContain("Pro Portfolio Calculator");
// a guide token must not open calcpro
const env2 = envMock();
await env2.KEYS.put("dlg:gtok", JSON.stringify({ product: "guide", ts: 1 }));
const c2 = makeCtx({ env: env2, method: "GET", query: { t: "gtok" }, url: "https://wmcp.sh/calcpro/read?t=gtok" });
const r2: any = await digitalRead(c2, "calcpro");
expect(r2.status).toBe(403);
});
});
Loading