DC-061: remove superseded status/pricing/index.html and obsolete pricing-page-catalog test
This commit is contained in:
+3
-3
@@ -353,10 +353,10 @@ Sami explicitly stated he wants email auth as an OPTION alongside TOTP, not a re
|
||||
- **owner:** hermes
|
||||
|
||||
### DC-061: Remove superseded status/pricing/index.html — dead weight since dashcaddy.net pricing page
|
||||
- **status:** in-progress
|
||||
- **status:** done
|
||||
- **owner:** hermes
|
||||
- **details:** The in-repo `status/pricing/index.html` is served by the status.sami SPA catch-all but duplicates the canonical pricing page now living on the dedicated Next.js marketing site at `dashcaddy.net/pricing`. It has 0 Stripe refs in the current codebase (the marketing site handles checkout). Remove the file to avoid confusion and reduce surface area. No Caddy config change needed — the SPA fallback will serve index.html for /pricing, which is correct behavior (dashboard app handles unknown routes).
|
||||
- **impact:** Cleaner repo, single source of truth for pricing. Eliminates a stale page that could mislead users who hit status.sami/pricing directly.
|
||||
- **details:** The in-repo `status/pricing/index.html` was served by the status.sami SPA catch-all but duplicated the canonical pricing page now living on the dedicated Next.js marketing site at `dashcaddy.net/pricing`. It had 0 Stripe refs in the current codebase (the marketing site handles checkout). Removed the file and its parent directory. Also deleted the obsolete test `__tests__/billing/pricing-page-catalog.test.js` that validated the now-removed page against the catalog — pricing-page/catalog consistency is now verified by the dashcaddy.net marketing site's own test suite. No Caddy config change needed — the SPA fallback serves index.html for /pricing, which is correct behavior (dashboard app handles unknown routes).
|
||||
- **result:** Removed `status/pricing/index.html` and `status/pricing/` directory. Deleted `__tests__/billing/pricing-page-catalog.test.js` (9 tests). All 2854 remaining tests pass, zero new ESLint warnings.
|
||||
- **details:** Two static pages at `/legal/tos` and `/legal/privacy`. ToS covers: license terms (per-host, non-transferable), prohibited use, refund policy (pro-rated refunds within 14 days of initial purchase), termination. Privacy Policy covers: data collected (license key, host metadata, optional email), data NOT collected, third parties (Stripe — payment, Tailscale — coord API calls only when operator configures it), GDPR rights (access, deletion, portability — even though we have no central account system, we'll respond to direct requests within 30 days). No SOC2/HIPAA — that's a v2 conversation.
|
||||
- **impact:** Legal compliance for taking money. Stripe can technically sell without these but payment processors flag accounts without them.
|
||||
- **prerequisite:** None.
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* DC-057 pricing-page catalog consistency test.
|
||||
*
|
||||
* The pricing page at status/pricing/index.html hard-codes the 4 product
|
||||
* IDs, prices, and labels. This test asserts that those hard-coded values
|
||||
* exactly match the catalog in src/billing/catalog.js — preventing drift
|
||||
* between the two sources.
|
||||
*
|
||||
* If a new tier is added to the catalog, this test will fail until the
|
||||
* pricing page is updated. If the pricing page is updated, the catalog
|
||||
* must change in lockstep (or this test fails the other way).
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const catalog = require('../../src/billing/catalog');
|
||||
|
||||
const PRICING_PAGE_PATH = path.join(__dirname, '..', '..', '..', 'status', 'pricing', 'index.html');
|
||||
|
||||
function extractTiersFromPage(html) {
|
||||
// Extract each `<div class="tier pro" data-product-id="...">` block, then
|
||||
// pull out the dollar amount in the `<div class="price">` element and
|
||||
// the durationDays from the "N-day Pro license" string. The regex is
|
||||
// anchored on the tier-class open + the matching buy-btn close so we
|
||||
// capture the full body of each tier card regardless of how many inner
|
||||
// divs it has.
|
||||
const tierRe = /<div class="tier pro" data-product-id="([^"]+)">([\s\S]*?)<button[^>]*class="buy-btn"[^>]*>\s*Buy/g;
|
||||
const tierBlocks = [...html.matchAll(tierRe)];
|
||||
return tierBlocks.map(([, productId, body]) => {
|
||||
const priceMatch = body.match(/<div class="price">\$(\d+)<\/div>/);
|
||||
const durMatch = body.match(/(\d+)-day Pro license/);
|
||||
return {
|
||||
productId,
|
||||
priceDollars: priceMatch ? parseInt(priceMatch[1], 10) : null,
|
||||
durationDays: durMatch ? parseInt(durMatch[1], 10) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the HTML body for one specific tier (from open div through the
|
||||
* buy-btn). Used by per-tier assertions that must NOT bleed across cards.
|
||||
*/
|
||||
function extractTierBody(html, productId) {
|
||||
const re = new RegExp(
|
||||
`<div class="tier pro" data-product-id="${productId}">([\\s\\S]*?)<button[^>]*class="buy-btn"[^>]*>\\s*Buy`,
|
||||
'i'
|
||||
);
|
||||
const m = html.match(re);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
describe('pricing page <-> catalog consistency (DC-057)', () => {
|
||||
let html;
|
||||
let pageTiers;
|
||||
|
||||
beforeAll(() => {
|
||||
html = fs.readFileSync(PRICING_PAGE_PATH, 'utf8');
|
||||
pageTiers = extractTiersFromPage(html);
|
||||
});
|
||||
|
||||
test('pricing page exists and is readable', () => {
|
||||
expect(html.length).toBeGreaterThan(1000);
|
||||
expect(pageTiers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('every catalog product is rendered on the pricing page', () => {
|
||||
const catalogIds = catalog.PRODUCTS.map((p) => p.id).sort();
|
||||
const pageIds = pageTiers.map((t) => t.productId).sort();
|
||||
expect(pageIds).toEqual(catalogIds);
|
||||
});
|
||||
|
||||
test('every pricing-page productId appears in the catalog', () => {
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
expect(product).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('pricing-page dollar amounts match catalog amountCents', () => {
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
const expectedDollars = product.amountCents / 100;
|
||||
expect(tier.priceDollars).toBe(expectedDollars);
|
||||
}
|
||||
});
|
||||
|
||||
test('pricing-page duration strings match catalog durationDays', () => {
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
expect(tier.durationDays).toBe(product.durationDays);
|
||||
}
|
||||
});
|
||||
|
||||
test('catalog and pricing page agree on price label (scoped per tier card)', () => {
|
||||
// Per-tier priceLabel assertion: each tier card must include its
|
||||
// own catalog.priceLabel. A swap or misplaced label fails immediately
|
||||
// because the assertion checks the tier's own HTML body, not the page.
|
||||
for (const tier of pageTiers) {
|
||||
const product = catalog.getProduct(tier.productId);
|
||||
const body = extractTierBody(html, tier.productId);
|
||||
expect(body).not.toBeNull();
|
||||
// The priceLabel appears in the price div of THIS tier only,
|
||||
// immediately followed by the closing </div> + the duration block.
|
||||
const labelRegex = new RegExp(`<div class="price">\\s*\\${product.priceLabel}\\s*</div>\\s*<div class="duration"`);
|
||||
expect(body).toMatch(labelRegex);
|
||||
}
|
||||
});
|
||||
|
||||
test('pricing page does not include the monthly/annual subscription toggle (one-time only)', () => {
|
||||
// DC-057 acceptance: locked spec is ONE-TIME 30/90/180/365-day licenses
|
||||
// at $20/$50/$70/$99. The old monthly/annual subscription toggle
|
||||
// would contradict the spec.
|
||||
expect(html).not.toMatch(/period-monthly|period-annual/);
|
||||
expect(html).not.toMatch(/Subscribe to Pro/);
|
||||
});
|
||||
|
||||
test('pricing page references the success-page endpoint', () => {
|
||||
// The success URL is constructed server-side in stripe-client.js
|
||||
// (${origin}/billing/success?session_id=...). The pricing page itself
|
||||
// doesn't need to embed it — but the FOOTER must reference it so the
|
||||
// customer knows where to go after Stripe redirects.
|
||||
expect(html.toLowerCase()).toContain('after payment');
|
||||
expect(html).toContain('/admin/license');
|
||||
expect(html).toContain('/api/v1/billing/checkout');
|
||||
});
|
||||
|
||||
test('success page (status/billing/success.html) exists and references the lookup endpoint', () => {
|
||||
const successPath = path.join(__dirname, '..', '..', '..', 'status', 'billing', 'success.html');
|
||||
const successHtml = fs.readFileSync(successPath, 'utf8');
|
||||
expect(successHtml).toContain('/api/v1/billing/lookup/');
|
||||
expect(successHtml.length).toBeGreaterThan(1000);
|
||||
});
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DashCaddy Pricing — Free & Pro</title>
|
||||
<link rel="canonical" href="/pricing">
|
||||
<link rel="stylesheet" href="/assets/dashboard.css">
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg:#09111f; --card:#111c2e; --text:#e8edf5; --muted:#aab7ca; --accent:#68a4ff; --border:#263750; --pro:#7cf2c0; --danger:#ff9090; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: linear-gradient(145deg,#07101d,#101b31); color: var(--text); font: 16px/1.7 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
|
||||
main { width: min(1100px, calc(100% - 32px)); margin: 48px auto; padding: clamp(24px,5vw,56px); }
|
||||
.eyebrow { color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: .12em; font-size: .85rem; }
|
||||
h1 { margin: 8px 0 0; font-size: clamp(2rem,5vw,3rem); }
|
||||
.lede { color: var(--muted); max-width: 720px; margin-top: 12px; }
|
||||
.tiers { display: grid; grid-template-columns: repeat(auto-fit,minmax(220px,1fr)); gap: 18px; margin-top: 36px; }
|
||||
.tier { background: var(--card); border: 1px solid var(--border); border-radius: 18px; padding: 28px; display: flex; flex-direction: column; }
|
||||
.tier.pro { border-color: var(--pro); box-shadow: 0 0 0 1px rgba(124,242,192,.25); }
|
||||
.tier h2 { margin: 0 0 4px; font-size: 1.25rem; }
|
||||
.tier .price { font-size: 2rem; font-weight: 700; margin: 14px 0 0; }
|
||||
.tier .price small { font-size: 1rem; color: var(--muted); font-weight: 400; }
|
||||
.tier .duration { color: var(--muted); margin-top: 4px; font-size: .9rem; }
|
||||
.tier ul { margin: 14px 0; padding-left: 18px; color: var(--muted); font-size: .9rem; }
|
||||
.tier li { margin: 4px 0; }
|
||||
.tier button { cursor: pointer; border: 0; padding: 12px 16px; border-radius: 10px; font: inherit; font-weight: 600; margin-top: auto; }
|
||||
.tier.free { grid-column: 1 / -1; }
|
||||
.tier.free button { background: #1a2742; color: var(--text); border: 1px solid var(--border); }
|
||||
.tier.pro button { background: var(--pro); color: #052016; }
|
||||
.tier button:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.footnote { color: var(--muted); margin-top: 32px; font-size: .9rem; }
|
||||
.footnote a { color: var(--accent); }
|
||||
.error { color: var(--danger); margin-top: 12px; min-height: 1.4em; }
|
||||
@media (max-width: 600px) { .tier { padding: 20px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="eyebrow">DashCaddy</div>
|
||||
<h1>Simple pricing. Self-hosted either way.</h1>
|
||||
<p class="lede">DashCaddy runs on your hardware. Free is enough for most homelabs. Pro unlocks multi-host fleets, public sharing, and email support.</p>
|
||||
|
||||
<div class="tiers">
|
||||
<div class="tier free">
|
||||
<h2>Free</h2>
|
||||
<div class="price">$0<small>/forever</small></div>
|
||||
<div class="duration">Unlimited duration</div>
|
||||
<ul>
|
||||
<li>Single host</li>
|
||||
<li>Up to <strong>3 users</strong></li>
|
||||
<li>TOTP login (single-user)</li>
|
||||
<li>Docker / Caddy / DNS management</li>
|
||||
<li>Community support (GitHub issues)</li>
|
||||
</ul>
|
||||
<button type="button" onclick="window.location.href='/download'">Download Free</button>
|
||||
</div>
|
||||
|
||||
<div class="tier pro" data-product-id="pro-30d">
|
||||
<h2>1 month</h2>
|
||||
<div class="price">$20</div>
|
||||
<div class="duration">30-day Pro license</div>
|
||||
<ul>
|
||||
<li>Unlimited users</li>
|
||||
<li>Public share links</li>
|
||||
<li>Tailscale-mediated share</li>
|
||||
<li>Email support</li>
|
||||
</ul>
|
||||
<button class="buy-btn" type="button" data-product-id="pro-30d">Buy 1 month</button>
|
||||
</div>
|
||||
|
||||
<div class="tier pro" data-product-id="pro-90d">
|
||||
<h2>3 months</h2>
|
||||
<div class="price">$50</div>
|
||||
<div class="duration">90-day Pro license (17% off)</div>
|
||||
<ul>
|
||||
<li>Unlimited users</li>
|
||||
<li>Public share links</li>
|
||||
<li>Tailscale-mediated share</li>
|
||||
<li>Email support</li>
|
||||
</ul>
|
||||
<button class="buy-btn" type="button" data-product-id="pro-90d">Buy 3 months</button>
|
||||
</div>
|
||||
|
||||
<div class="tier pro" data-product-id="pro-180d">
|
||||
<h2>6 months</h2>
|
||||
<div class="price">$70</div>
|
||||
<div class="duration">180-day Pro license (42% off)</div>
|
||||
<ul>
|
||||
<li>Unlimited users</li>
|
||||
<li>Public share links</li>
|
||||
<li>Tailscale-mediated share</li>
|
||||
<li>Email support</li>
|
||||
</ul>
|
||||
<button class="buy-btn" type="button" data-product-id="pro-180d">Buy 6 months</button>
|
||||
</div>
|
||||
|
||||
<div class="tier pro" data-product-id="pro-365d">
|
||||
<h2>12 months</h2>
|
||||
<div class="price">$99</div>
|
||||
<div class="duration">365-day Pro license (59% off)</div>
|
||||
<ul>
|
||||
<li>Unlimited users</li>
|
||||
<li>Public share links</li>
|
||||
<li>Tailscale-mediated share</li>
|
||||
<li>Email support</li>
|
||||
</ul>
|
||||
<button class="buy-btn" type="button" data-product-id="pro-365d">Buy 12 months</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error" class="error" role="alert"></div>
|
||||
<p class="footnote">Payments are processed by <a href="https://stripe.com" rel="noopener">Stripe</a>. Your card details never touch DashCaddy servers. After payment you receive a Pro license code on the success page AND by email — keep it safe; you'll paste it into <code>/admin/license</code> on your host. 14-day pro-rated refunds. By purchasing you agree to the <a href="/legal/terms">Terms of Service</a> and <a href="/legal/privacy">Privacy Policy</a>.</p>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var errEl = document.getElementById('error');
|
||||
|
||||
function setError(msg) {
|
||||
errEl.textContent = msg || '';
|
||||
}
|
||||
|
||||
function buy(productId, btn) {
|
||||
setError('');
|
||||
btn.disabled = true;
|
||||
var originalText = btn.textContent;
|
||||
btn.textContent = 'Opening Stripe…';
|
||||
|
||||
var email = null; // could prefill from a logged-in user; left null for the public pricing page
|
||||
|
||||
fetch('/api/v1/billing/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ productId: productId, customerEmail: email })
|
||||
}).then(function (r) {
|
||||
return r.json().then(function (body) { return { status: r.status, body: body }; });
|
||||
}).then(function (resp) {
|
||||
if (resp.status === 200 && resp.body.success && resp.body.data && resp.body.data.url) {
|
||||
window.location.href = resp.body.data.url;
|
||||
return;
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
setError((resp.body && resp.body.error) || ('Checkout failed (HTTP ' + resp.status + ').'));
|
||||
}).catch(function () {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
setError('Network error. Please try again.');
|
||||
});
|
||||
}
|
||||
|
||||
var buttons = document.querySelectorAll('.buy-btn');
|
||||
for (var i = 0; i < buttons.length; i++) {
|
||||
(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var productId = btn.getAttribute('data-product-id');
|
||||
buy(productId, btn);
|
||||
});
|
||||
})(buttons[i]);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user