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
1 change: 1 addition & 0 deletions docs/commercial/checkout-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ This runbook moves Dictx Pro sales to `https://dictx.splitlabs.io/buy` while kee
- Product name: `Dictx Pro`
- Offer copy: `Signed binaries, auto-updates, and direct support`
- Price: `USD 29 one-time`
- Success URL: `https://dictx.splitlabs.io/buy/success?checkout_id={CHECKOUT_ID}`

3. Enable customer portal for:

Expand Down
6 changes: 6 additions & 0 deletions landing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ Attach `dictx.splitlabs.io` to this Vercel project.
- `POLAR_ACCESS_TOKEN`: Polar API token
- `POLAR_DICTX_PRODUCT_IDS`: optional comma-separated product IDs allowed for Dictx Pro activation
- `POLAR_API_BASE`: optional override (defaults to `https://api.polar.sh/v1`)

## Polar Checkout Success URL

Set the product checkout success URL to:

- `https://dictx.splitlabs.io/buy/success?checkout_id={CHECKOUT_ID}`
67 changes: 67 additions & 0 deletions landing/api/pro/verify.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ const ALLOWED_PRODUCT_IDS = (process.env.POLAR_DICTX_PRODUCT_IDS || "")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
const RATE_LIMIT_WINDOW_MS = Number.parseInt(
process.env.PRO_VERIFY_RATE_LIMIT_WINDOW_MS || "60000",
10,
);
const RATE_LIMIT_MAX = Number.parseInt(
process.env.PRO_VERIFY_RATE_LIMIT_MAX || "20",
10,
);
const MAX_LICENSE_KEY_LENGTH = 128;
const LICENSE_KEY_PATTERN = /^polar_cl_[A-Za-z0-9]+$/;
const requestBuckets = new Map();

const readBody = (req) => {
if (!req.body) return {};
Expand All @@ -28,6 +39,39 @@ const statusLooksPaid = (status, paid) => {
);
};

const getClientId = (req) => {
const forwarded = req.headers["x-forwarded-for"];
if (Array.isArray(forwarded)) {
return forwarded[0] || "unknown";
}
if (typeof forwarded === "string" && forwarded.length > 0) {
const [first] = forwarded.split(",");
return first.trim() || "unknown";
}
return req.socket?.remoteAddress || "unknown";
};

const isRateLimited = (clientId) => {
const now = Date.now();
if (requestBuckets.size > 5000) {
for (const [key, bucket] of requestBuckets.entries()) {
if (now > bucket.resetAt) {
requestBuckets.delete(key);
}
}
}

const existing = requestBuckets.get(clientId);
if (!existing || now > existing.resetAt) {
requestBuckets.set(clientId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
return false;
}

existing.count += 1;
requestBuckets.set(clientId, existing);
return existing.count > RATE_LIMIT_MAX;
};

module.exports = async (req, res) => {
if (req.method !== "POST") {
res.status(405).json({ error: "method_not_allowed" });
Expand All @@ -39,6 +83,12 @@ module.exports = async (req, res) => {
return;
}

const clientId = getClientId(req);
if (isRateLimited(clientId)) {
res.status(429).json({ error: "rate_limited" });
return;
}

const body = readBody(req);
const licenseKey = (body.licenseKey || body.checkoutId || "").trim();

Expand All @@ -47,6 +97,18 @@ module.exports = async (req, res) => {
return;
}

if (licenseKey.length > MAX_LICENSE_KEY_LENGTH) {
console.warn("pro_verify_invalid_key_length", { clientId });
res.status(400).json({ error: "invalid_license_key" });
return;
}

if (!LICENSE_KEY_PATTERN.test(licenseKey)) {
console.warn("pro_verify_invalid_key_format", { clientId });
res.status(400).json({ error: "invalid_license_key" });
return;
}

try {
const response = await fetch(
`${POLAR_API_BASE}/checkouts/${encodeURIComponent(licenseKey)}`,
Expand All @@ -66,6 +128,10 @@ module.exports = async (req, res) => {

if (!response.ok) {
const text = await response.text();
console.warn("pro_verify_polar_api_error", {
status: response.status,
clientId,
});
res.status(502).json({ error: "polar_api_error", detail: text });
return;
}
Expand All @@ -79,6 +145,7 @@ module.exports = async (req, res) => {

res.status(200).json({ active: Boolean(productAllowed && paid) });
} catch (error) {
console.warn("pro_verify_internal_error", { clientId });
res.status(500).json({
error: "internal_error",
detail: error instanceof Error ? error.message : String(error),
Expand Down
75 changes: 75 additions & 0 deletions landing/buy/success/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dictx Pro Activated</title>
<meta
name="description"
content="Your Dictx Pro purchase is complete. Copy your license key and activate in the app."
/>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="noise"></div>

<header class="shell nav">
<a class="brand" href="/">
<img class="brand-icon" src="/logo.png" alt="Dictx logo" />
<span class="wordmark">Dictx</span>
</a>
<div class="menu">
<a href="/">Home</a>
</div>
</header>

<main class="shell">
<section class="panel hero">
<p class="kicker">Purchase Complete</p>
<h1>Activate Dictx Pro</h1>
<p class="lede">
Copy your license key and paste it in Dictx:
<strong>Settings -> About -> Activate Dictx Pro</strong>.
</p>

<div class="card" style="margin-top: 16px">
<h2 style="font-size: 18px">Your License Key</h2>
<p id="license-key-value" style="word-break: break-all; font-family: monospace">
Not found in URL. Add <code>?checkout_id=polar_cl_...</code>.
</p>
<div class="actions" style="margin-top: 12px">
<button id="copy-btn" class="btn primary" type="button" disabled>
Copy License Key
</button>
<a class="btn ghost" href="/">Back to Dictx</a>
</div>
<p id="copy-status" class="meta" style="margin-top: 8px"></p>
</div>
</section>
</main>

<script>
const params = new URLSearchParams(window.location.search);
const key = (params.get("license_key") || params.get("checkout_id") || "").trim();
const valueEl = document.getElementById("license-key-value");
const copyBtn = document.getElementById("copy-btn");
const copyStatus = document.getElementById("copy-status");

if (key) {
valueEl.textContent = key;
copyBtn.disabled = false;
}

copyBtn.addEventListener("click", async () => {
if (!key) return;
try {
await navigator.clipboard.writeText(key);
copyStatus.textContent = "Copied. Paste it in Dictx Settings -> About.";
} catch (_error) {
copyStatus.textContent = "Copy failed. Select and copy manually.";
}
});
</script>
</body>
</html>
22 changes: 22 additions & 0 deletions src/components/settings/about/AboutSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const AboutSettings: React.FC = () => {
const { t } = useTranslation();
const [version, setVersion] = useState("");
const [licenseKey, setLicenseKey] = useState("");
const [clipboardError, setClipboardError] = useState<string | null>(null);
const {
entitlement,
isSubmitting: proSubmitting,
Expand Down Expand Up @@ -44,6 +45,16 @@ export const AboutSettings: React.FC = () => {
}
};

const handlePasteFromClipboard = async () => {
try {
setClipboardError(null);
const text = await navigator.clipboard.readText();
setLicenseKey(text.trim());
} catch (_error) {
setClipboardError(t("settings.about.proActivation.clipboardFailed"));
}
};

return (
<div className="max-w-3xl w-full mx-auto space-y-6">
<SettingsGroup title={t("settings.about.title")}>
Expand Down Expand Up @@ -92,6 +103,14 @@ export const AboutSettings: React.FC = () => {
placeholder={t("settings.about.proActivation.licenseKeyPlaceholder")}
disabled={proSubmitting}
/>
<Button
variant="secondary"
size="sm"
onClick={() => void handlePasteFromClipboard()}
disabled={proSubmitting}
>
{t("settings.about.proActivation.pasteFromClipboard")}
</Button>
<Button
variant="primary"
size="md"
Expand All @@ -100,6 +119,9 @@ export const AboutSettings: React.FC = () => {
>
{t("settings.about.proActivation.activate")}
</Button>
{clipboardError && (
<p className="text-xs text-red-400">{clipboardError}</p>
)}
{proError && <p className="text-xs text-red-400">{proError}</p>}
{entitlement?.verification_error && (
<p className="text-xs text-red-400">
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,9 @@
"refresh": "Re-check Entitlement",
"clear": "Remove Activation",
"licenseKey": "License Key",
"licenseKeyPlaceholder": "polar_cl_..."
"licenseKeyPlaceholder": "polar_cl_...",
"pasteFromClipboard": "Paste From Clipboard",
"clipboardFailed": "Clipboard access failed. Paste manually."
},
"acknowledgments": {
"title": "Acknowledgments",
Expand Down
Loading