nixamp 0.67.0 → 0.67.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/ai-label.d.ts +17 -4
  2. package/dist/ai-label.js +39 -9
  3. package/dist/license-routes.js +6 -13
  4. package/dist/music-license.js +4 -3
  5. package/dist/music-store.js +4 -3
  6. package/dist/server.js +76 -15
  7. package/dist/store-routes.js +6 -14
  8. package/dist/support.d.ts +77 -8
  9. package/dist/support.js +229 -93
  10. package/package.json +2 -2
  11. package/src/ai-label.ts +45 -10
  12. package/src/license-routes.ts +6 -13
  13. package/src/music-license.ts +4 -3
  14. package/src/music-store.ts +4 -3
  15. package/src/server.ts +59 -9
  16. package/src/store-routes.ts +6 -14
  17. package/src/support.ts +227 -90
  18. package/web/dist/assets/{ar-Dw3s9ADu.js → ar-DxGw3Rs5.js} +1 -1
  19. package/web/dist/assets/{cs-CzDFyr4i.js → cs-CvhFUJ-M.js} +1 -1
  20. package/web/dist/assets/{da-DCTQgBaR.js → da-DNNaWmG1.js} +1 -1
  21. package/web/dist/assets/{de-bRuy-6md.js → de-Bd73nJDj.js} +1 -1
  22. package/web/dist/assets/{es-iHd4cFjq.js → es-kql7koI9.js} +1 -1
  23. package/web/dist/assets/{fi-BQgHm0iD.js → fi-DOSVk45m.js} +1 -1
  24. package/web/dist/assets/{fr-Bx2XC-ON.js → fr-C-PWAVGY.js} +1 -1
  25. package/web/dist/assets/{hi-Cq8IDrah.js → hi-DSccyns6.js} +1 -1
  26. package/web/dist/assets/{hu-C3mzcLs_.js → hu-nhOx8KN8.js} +1 -1
  27. package/web/dist/assets/{id-DddaqO3C.js → id-B_7XcFMO.js} +1 -1
  28. package/web/dist/assets/{it-C8TvaS5w.js → it-DS_wAQLZ.js} +1 -1
  29. package/web/dist/assets/main-DBqNZR5i.js +22 -0
  30. package/web/dist/assets/{nl-C3LqPPhu.js → nl-E4kRa4hT.js} +1 -1
  31. package/web/dist/assets/{ru-DzAMilh-.js → ru-BBTDvHvj.js} +1 -1
  32. package/web/dist/assets/{sv-Ca1BMi0N.js → sv-eb2QkbPB.js} +1 -1
  33. package/web/dist/assets/{uk-D-CADfyO.js → uk-D8ewz76c.js} +1 -1
  34. package/web/dist/assets/{vi-CWeZdHAD.js → vi-DjxETiKX.js} +1 -1
  35. package/web/dist/assets/{zh-BvS0BRgy.js → zh-pjVIcKxb.js} +1 -1
  36. package/web/dist/index.html +2 -3
  37. package/web/dist/sw.js +19 -19
  38. package/web/dist/assets/main-BSOJX0Of.js +0 -22
@@ -13,7 +13,7 @@
13
13
  import { createHash } from "node:crypto";
14
14
  import type { IncomingMessage, ServerResponse } from "node:http";
15
15
  import { LICENSE_PRICES, TERMS_VERSION, TIER_NAMES, TIER_USE, licenseTerms, type License, type MusicLicenses, type Quote, type Tier } from "./music-license.ts";
16
- import { SupportError } from "./support.ts";
16
+ import { SupportError, paidIn, payUrl } from "./support.ts";
17
17
 
18
18
  const CHECK = /^\/license\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
19
19
  const CHECK_JSON = /^\/api\/v1\/licenses\/(NXL-[A-Z2-9]{4}-[A-Z2-9]{4})$/;
@@ -74,14 +74,9 @@ const SCRIPT = `
74
74
  if (!form) return;
75
75
  const status = $("#status");
76
76
  const pay = $("#pay");
77
- const label = () => { const one = form.querySelector("input[name=tier]:checked"); pay.textContent = one ? "Pay " + one.dataset.price + " with crypto" : "Pick a licence"; };
77
+ const label = () => { const one = form.querySelector("input[name=tier]:checked"); pay.textContent = one ? "Pay " + one.dataset.price : "Pick a licence"; };
78
78
  form.addEventListener("change", label);
79
79
  label();
80
- fetch("/api/v1/store/coins").then((r) => r.json()).then((coins) => {
81
- const cheap = ["USDC_POL", "USDC_SOL", "SOL", "POL", "LTC", "USDC_BASE", "USDC"];
82
- const list = (coins.coins || []).slice().sort((a, b) => (cheap.indexOf(a) + 1 || 99) - (cheap.indexOf(b) + 1 || 99));
83
- $("#coin").replaceChildren(...list.map((c) => Object.assign(document.createElement("option"), { value: c, textContent: c })));
84
- }).catch(() => { status.textContent = "Payments are not available right now."; });
85
80
  form.addEventListener("submit", async (event) => {
86
81
  event.preventDefault();
87
82
  pay.disabled = true;
@@ -90,7 +85,7 @@ const SCRIPT = `
90
85
  try {
91
86
  const r = await fetch("/api/v1/licenses/checkout", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
92
87
  body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), tier: data.get("tier"), licensee: data.get("licensee"), project: data.get("project"),
93
- projectUrl: data.get("projectUrl"), email: data.get("email"), coin: data.get("coin"), agree: data.get("agree") === "on" }) });
88
+ projectUrl: data.get("projectUrl"), email: data.get("email"), agree: data.get("agree") === "on" }) });
94
89
  const body = await r.json();
95
90
  if (!r.ok) throw new Error(body.error || "The checkout did not start.");
96
91
  status.textContent = "Taking you to CoinPay…";
@@ -181,13 +176,11 @@ export async function handleLicenseRoutes(request: IncomingMessage, response: Se
181
176
  <input id="projectUrl" name="projectUrl" type="url" maxlength="300" placeholder="https://">
182
177
  <label class="f" for="email">Email (your certificate goes here)</label>
183
178
  <input id="email" name="email" type="email" required autocomplete="email" value="${escape(me?.email ?? "")}">
184
- <label class="f" for="coin">Pay with</label>
185
- <select id="coin" name="coin" required></select>
186
179
  <label class="agree"><input type="checkbox" name="agree" required> <span>I agree to the <a href="/license/terms" target="_blank" rel="noopener">licence terms</a>, including the credit with a link to the artist's page.</span></label>
187
180
  <button id="pay" type="submit">Pay</button>
188
181
  </form>
189
182
  <p id="status" role="status" aria-live="polite"></p>
190
- <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com. You get a certificate with a licence code that anybody can check at ${escape(ctx.site)}/license/&lt;code&gt;.</small>
183
+ <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com: pick any coin, or PayPal, on CoinPay's page. You get a certificate with a licence code that anybody can check at ${escape(ctx.site)}/license/&lt;code&gt;.</small>
191
184
  </div>`, { data: JSON.stringify({ kind: quote.kind, ...(quote.kind === "catalog" ? {} : { server: ctx.serverId(quote.server), artist: quote.artist, album: quote.album, n: quote.n }) }) }), method);
192
185
  return true;
193
186
  }
@@ -214,9 +207,9 @@ export async function handleLicenseRoutes(request: IncomingMessage, response: Se
214
207
  }
215
208
  const body = license.status === "paid"
216
209
  ? certificate(license, ctx, true)
217
- : `<div class="card"><h1>${escape(TIER_NAMES[license.tier])} licence ${escape(license.code)}</h1><p class="by">${escape(license.title)} · ${price(license.amountCents)} in ${escape(license.coin)}</p>
210
+ : `<div class="card"><h1>${escape(TIER_NAMES[license.tier])} licence ${escape(license.code)}</h1><p class="by">${escape(license.title)} · ${price(license.amountCents)}${escape(paidIn(license.coin))}</p>
218
211
  <p>Waiting for your payment. Once CoinPay confirms it, your certificate is here and in your email (${escape(license.email)}). This page checks again when you reload it.</p>
219
- <a class="go" href="https://coinpayportal.com/pay/${escape(license.paymentId)}">Open the payment</a></div>`;
212
+ <a class="go" href="${escape(payUrl(license.paymentId))}">Open the payment</a></div>`;
220
213
  html(response, 200, page(`Licence ${license.code}`, body), method);
221
214
  return true;
222
215
  }
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { randomBytes, randomUUID } from "node:crypto";
19
19
  import type { Db, Support } from "./support.ts";
20
- import { SupportError } from "./support.ts";
20
+ import { SupportError, coinOf } from "./support.ts";
21
21
  import type { MusicStore } from "./music-store.ts";
22
22
 
23
23
  export type Tier = "creator" | "indie" | "commercial" | "catalog";
@@ -203,7 +203,8 @@ export class MusicLicenses {
203
203
  const code = newCode();
204
204
  const checkout = await this.options.support.pay({
205
205
  amountUsd: quote.amountCents / 100,
206
- coin: input["coin"],
206
+ email,
207
+ accountId: account?.id || email,
207
208
  order: `license-${id}`,
208
209
  description: `${TIER_NAMES[quote.tier]} licence: ${quote.title}${quote.kind === "catalog" ? ", one year" : ` by ${quote.artistName}`} for ${project}`,
209
210
  metadata: { product: "music-license", license: id, tier: quote.tier, kind: quote.kind },
@@ -213,7 +214,7 @@ export class MusicLicenses {
213
214
  title, artist_name, credit_url, amount_cents, coin, payment_id, terms, created_at)
214
215
  VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22)`,
215
216
  [id, code, token, email, account?.id ?? "", licensee, project, httpsUrl(input["projectUrl"]), quote.tier, quote.kind, quote.server, quote.artist, quote.album, quote.n,
216
- quote.title, quote.artistName, quote.creditUrl, quote.amountCents, String(input["coin"]).toUpperCase(), checkout.paymentId, TERMS_VERSION, new Date(this.now())]);
217
+ quote.title, quote.artistName, quote.creditUrl, quote.amountCents, coinOf(input["coin"]), checkout.paymentId, TERMS_VERSION, new Date(this.now())]);
217
218
  return { url: checkout.url, id, token, code };
218
219
  }
219
220
 
@@ -17,7 +17,7 @@
17
17
  */
18
18
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
19
19
  import type { Db, Support } from "./support.ts";
20
- import { SupportError } from "./support.ts";
20
+ import { SupportError, coinOf } from "./support.ts";
21
21
 
22
22
  export type Kind = "song" | "album" | "artist";
23
23
 
@@ -225,7 +225,8 @@ export class MusicStore {
225
225
  const token = randomUUID().replace(/-/g, "");
226
226
  const checkout = await this.options.support.pay({
227
227
  amountUsd: item.amountCents / 100,
228
- coin: input["coin"],
228
+ email,
229
+ accountId: account?.id || email,
229
230
  order: `music-${id}`,
230
231
  description: `${item.title} by ${item.artistName} (${item.kind === "song" ? "song" : item.kind === "album" ? "album" : "every album"}, MP3)`,
231
232
  metadata: { product: "music", purchase: id, kind: item.kind },
@@ -233,7 +234,7 @@ export class MusicStore {
233
234
  });
234
235
  await this.options.db.query(`INSERT INTO music_purchases (id, token, email, user_id, server, kind, artist, album, n, title, artist_name, amount_cents, coin, payment_id, created_at)
235
236
  VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
236
- [id, token, email, account?.id ?? "", item.server, item.kind, item.artist, item.album, item.n, item.title, item.artistName, item.amountCents, String(input["coin"]).toUpperCase(), checkout.paymentId, new Date(this.now())]);
237
+ [id, token, email, account?.id ?? "", item.server, item.kind, item.artist, item.album, item.n, item.title, item.artistName, item.amountCents, coinOf(input["coin"]), checkout.paymentId, new Date(this.now())]);
237
238
  return { url: checkout.url, id, token };
238
239
  }
239
240
 
package/src/server.ts CHANGED
@@ -2726,10 +2726,15 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2726
2726
  }
2727
2727
  let event: ReturnType<typeof readCoinpayWebhook>;
2728
2728
  try { event = readCoinpayWebhook(JSON.parse(raw)); } catch { json(response, 400, { error: "invalid JSON" }); return; }
2729
+ // A hosted checkout session's payment names its session; the purchase
2730
+ // row holds the session id (cs_...), an older one the payment id.
2731
+ const refs = [event.sessionId, event.paymentId].filter(Boolean);
2729
2732
  if (options.musicStore) {
2730
2733
  try {
2731
- if (await options.musicStore.owns(event.paymentId)) { json(response, 200, await options.musicStore.settle(event.paymentId)); return; }
2732
- if (options.musicLicenses && await options.musicLicenses.owns(event.paymentId)) { json(response, 200, await options.musicLicenses.settle(event.paymentId)); return; }
2734
+ for (const ref of refs) {
2735
+ if (await options.musicStore.owns(ref)) { json(response, 200, await options.musicStore.settle(ref)); return; }
2736
+ if (options.musicLicenses && await options.musicLicenses.owns(ref)) { json(response, 200, await options.musicLicenses.settle(ref)); return; }
2737
+ }
2733
2738
  } catch (error) {
2734
2739
  console.error(`nixamp: CoinPay webhook for ${event.paymentId} (music) could not settle: ${error instanceof Error ? error.message : String(error)}`);
2735
2740
  json(response, 500, { error: "could not settle; retry" });
@@ -2738,7 +2743,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2738
2743
  }
2739
2744
  if (!options.translationPasses) { json(response, 200, { settled: false, reason: "billing is off here" }); return; }
2740
2745
  try {
2741
- json(response, 200, await options.translationPasses.settle(event.paymentId));
2746
+ json(response, 200, await options.translationPasses.settle(event));
2742
2747
  } catch (error) {
2743
2748
  // A 5xx makes CoinPay deliver it again, which is what an unreachable
2744
2749
  // CoinPay or database needs.
@@ -2884,7 +2889,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2884
2889
  // llms.txt, the OpenAPI document, the health check and the desktop downloads (nixamp.com).
2885
2890
  !(isDiscoveryPath(path) && Boolean(options.accounts && options.site)) &&
2886
2891
  // Supporting a copyright holder is for anybody listening.
2887
- !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2892
+ !/^\/support\/[a-z0-9-]{1,40}$/.test(path) && path !== "/support/cancel" && !/^\/support\/pay\/[0-9a-f-]{36}\/[0-9a-f]{32}$/.test(path) && !/^\/api\/v1\/support\/[a-z0-9-]{1,40}$/.test(path) &&
2888
2893
  // A station's short link, which sends the browser to wherever it plays.
2889
2894
  !/^\/watch\/[a-z0-9]{10}$/.test(path) &&
2890
2895
  // A live room's page: the room's own key travels in ?play=, checked by its server.
@@ -2979,6 +2984,41 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2979
2984
  return;
2980
2985
  }
2981
2986
 
2987
+ /*
2988
+ * A monthly support's "pay this month" link, from its emails: a fresh
2989
+ * CoinPay checkout session each time it is followed (one still open is
2990
+ * reused), so a late payer is never sent to an expired session.
2991
+ */
2992
+ const supportPay = /^\/support\/pay\/([0-9a-f-]{36})\/([0-9a-f]{32})$/.exec(path);
2993
+ if (supportPay && options.supportSubscriptions && (request.method === "GET" || request.method === "HEAD")) {
2994
+ const page = (status: number, words: string, again?: string): void => {
2995
+ const safe = (text: string): string => text.replace(/[&<>"']/g, (one) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[one]!);
2996
+ response.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'", "referrer-policy": "no-referrer", "x-robots-tag": "noindex" });
2997
+ response.end(request.method === "HEAD" ? undefined : `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Support · nixamp</title><body style="margin:0;background:#080c09;color:#e8efe9;font:16px/1.5 system-ui,sans-serif"><main style="max-width:520px;margin:0 auto;padding:48px 16px"><h1 style="font-size:1.4rem">Monthly support</h1><p>${safe(words)}</p>${again ? `<p><a style="color:#3ddc84" href="${safe(again)}">${safe(again.includes("/support/pay/") ? "Try again" : "Start again")}</a></p>` : ""}<p><a style="color:#3ddc84" href="https://nixamp.com/guide">Back to the stations</a></p></main></body>`);
2998
+ };
2999
+ // A HEAD (a mail scanner checking the link) never opens a checkout.
3000
+ if (request.method === "HEAD") { page(200, ""); return; }
3001
+ const verdict = guard.check(`support-pay:${callerOf(request.headers, request.socket.remoteAddress, behindProxy)}`, { allowed: 10, windowMs: 60_000 });
3002
+ if (!verdict.ok) { page(429, "Too many at once; wait a minute and open the link again."); return; }
3003
+ try {
3004
+ const opened = await options.supportSubscriptions.open(supportPay[1]!, supportPay[2]!);
3005
+ if (opened.state === "redirect") {
3006
+ response.writeHead(303, { location: opened.url, "cache-control": "no-store", "referrer-policy": "no-referrer" });
3007
+ response.end();
3008
+ return;
3009
+ }
3010
+ const name = options.support?.holder(opened.holder)?.name ?? opened.holder;
3011
+ const site = options.site ?? "https://nixamp.com";
3012
+ if (opened.state === "paid") page(200, `This month's support for ${name} is paid. Thank you. Next month's link comes by email.`);
3013
+ else if (opened.state === "canceled") page(200, `Your monthly support for ${name} was cancelled, so there is nothing to pay.`, `${site}/support/${opened.holder}`);
3014
+ else page(200, `Your monthly support for ${name} stopped after a month went unpaid. Nothing is owed.`, `${site}/support/${opened.holder}`);
3015
+ } catch (error) {
3016
+ if (error instanceof SupportError) page(error.status, error.message, error.status >= 500 ? `${url.pathname}` : undefined);
3017
+ else page(502, "The payment service did not answer; nothing was charged. Try the link again in a minute.", url.pathname);
3018
+ }
3019
+ return;
3020
+ }
3021
+
2982
3022
  /* Support a copyright holder: the page, the coins, and a CoinPay checkout. */
2983
3023
  const supportPage = /^\/support\/([a-z0-9-]{1,40})$/.exec(path);
2984
3024
  if (supportPage && options.support && (request.method === "GET" || request.method === "HEAD")) {
@@ -3012,11 +3052,18 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3012
3052
  }
3013
3053
  let body: Record<string, unknown> = {};
3014
3054
  try { body = JSON.parse(await readBody(request)) as Record<string, unknown>; } catch { body = {}; }
3055
+ // CoinPay files the payment under the payer: a signed-in member by
3056
+ // account id (their email if the form left it empty), a guest by email.
3057
+ const me = options.accounts ? await options.accounts.whoIs(tokenFrom(request.headers)).catch(() => null) : null;
3058
+ const payer = {
3059
+ email: typeof body["email"] === "string" && body["email"].trim() ? body["email"] : me?.email ?? "",
3060
+ accountId: me?.id ?? null,
3061
+ };
3015
3062
  if (body["monthly"] === true) {
3016
3063
  if (!options.supportSubscriptions) throw new SupportError("Monthly support is not set up here.", 503, "unavailable");
3017
- json(response, 200, await options.supportSubscriptions.start({ ...body, holder: holder.id }));
3064
+ json(response, 200, await options.supportSubscriptions.start({ ...body, ...payer, holder: holder.id }));
3018
3065
  } else {
3019
- json(response, 200, await support.checkout({ ...body, holder: holder.id }));
3066
+ json(response, 200, await support.checkout({ ...body, ...payer, holder: holder.id }));
3020
3067
  }
3021
3068
  return;
3022
3069
  }
@@ -4107,8 +4154,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
4107
4154
  if (!guard.check(`translation-checkout-ip:${caller}`, { allowed: 10, windowMs: 3600_000 }).ok) throw new SpeechError("Too many new checkouts. Resume your pending purchase.", 429);
4108
4155
  let body: Record<string, unknown>;
4109
4156
  try { body = JSON.parse(await readBody(request, 1024)); } catch { throw new SpeechError("Choose a translation pass.", 400); }
4110
- if (!body || typeof body !== "object" || typeof body["plan"] !== "string" || typeof body["coin"] !== "string" || typeof body["requestKey"] !== "string") throw new SpeechError("Choose a translation pass and payment currency.", 400);
4111
- json(response, 200, await service.checkout(who.id, body["plan"], body["coin"], body["requestKey"])); return;
4157
+ if (!body || typeof body !== "object" || typeof body["plan"] !== "string" || typeof body["requestKey"] !== "string") throw new SpeechError("Choose a translation pass.", 400);
4158
+ // A CoinPay hosted session (any coin or PayPal) filed under this
4159
+ // account and its email; `coin` only comes from an older open page.
4160
+ const email = who.email || (typeof body["email"] === "string" ? body["email"] : "");
4161
+ json(response, 200, await service.checkout(who.id, body["plan"], typeof body["coin"] === "string" ? body["coin"] : "", body["requestKey"], { email, accountId: who.id })); return;
4112
4162
  }
4113
4163
  const order = path.match(/^\/api\/v1\/translation-passes\/orders\/([^/]+)$/);
4114
4164
  if (order && request.method === "GET") {
@@ -8019,7 +8069,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
8019
8069
  ? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite, admins: [options.owner || ""] }) : undefined;
8020
8070
  // Payments to the copyright holders nixamp.com collects for, on its own CoinPay business.
8021
8071
  const supportPayments = accounts ? new Support({ key: coinpayKeyFrom(), site: nixampSite, holders: supportHoldersFrom() }) : undefined;
8022
- // Monthly support: each month a new CoinPay payment and its link by email.
8072
+ // Monthly support: each month its stable pay link by email (a fresh CoinPay session per click).
8023
8073
  const supportMonthly = supportPayments && supportPayments.enabled && pool ? new SupportSubscriptions({
8024
8074
  db: pool as unknown as import("./support.ts").Db,
8025
8075
  support: supportPayments,
@@ -13,7 +13,7 @@ import { createHash } from "node:crypto";
13
13
  import type { IncomingMessage, ServerResponse } from "node:http";
14
14
  import { PRICES, type MusicStore } from "./music-store.ts";
15
15
  import { serverId } from "./music-hub.ts";
16
- import { SupportError } from "./support.ts";
16
+ import { SupportError, paidIn, payUrl } from "./support.ts";
17
17
 
18
18
  const PURCHASE = /^\/purchases\/([0-9a-f-]{36})$/;
19
19
  const ONE = /^\/api\/v1\/store\/purchases\/([0-9a-f-]{36})$/;
@@ -61,12 +61,6 @@ const BUY_SCRIPT = `
61
61
  (async () => {
62
62
  const $ = (s) => document.querySelector(s);
63
63
  const status = $("#status");
64
- try {
65
- const coins = await (await fetch("/api/v1/store/coins")).json();
66
- const cheap = ["USDC_POL", "USDC_SOL", "SOL", "POL", "LTC", "USDC_BASE", "USDC"];
67
- const list = (coins.coins || []).slice().sort((a, b) => (cheap.indexOf(a) + 1 || 99) - (cheap.indexOf(b) + 1 || 99));
68
- $("#coin").replaceChildren(...list.map((c) => Object.assign(document.createElement("option"), { value: c, textContent: c })));
69
- } catch { status.textContent = "Payments are not available right now."; }
70
64
  $("#buy").addEventListener("submit", async (event) => {
71
65
  event.preventDefault();
72
66
  const button = $("#pay");
@@ -75,7 +69,7 @@ const BUY_SCRIPT = `
75
69
  const form = new FormData(event.currentTarget);
76
70
  try {
77
71
  const r = await fetch("/api/v1/store/checkout", { method: "POST", headers: { "content-type": "application/json" }, credentials: "same-origin",
78
- body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), email: form.get("email"), coin: form.get("coin") }) });
72
+ body: JSON.stringify({ ...JSON.parse(document.body.dataset.item), email: form.get("email") }) });
79
73
  const body = await r.json();
80
74
  if (!r.ok) throw new Error(body.error || "The checkout did not start.");
81
75
  status.textContent = "Taking you to CoinPay…";
@@ -128,12 +122,10 @@ export async function handleStoreRoutes(request: IncomingMessage, response: Serv
128
122
  <form id="buy">
129
123
  <label for="email">Email (your download link goes here)</label>
130
124
  <input id="email" name="email" type="email" required autocomplete="email" value="${escape(me?.email ?? "")}">
131
- <label for="coin">Pay with</label>
132
- <select id="coin" name="coin" required></select>
133
- <button id="pay" type="submit">Pay ${price(item.amountCents)} with crypto</button>
125
+ <button id="pay" type="submit">Pay ${price(item.amountCents)}</button>
134
126
  </form>
135
127
  <p id="status" role="status" aria-live="polite"></p>
136
- <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com. For small amounts pick a coin with low fees (USDC on Polygon or Solana, SOL, LTC). Listening on the live stations is always free.${me ? "" : ` <a href="/?redirect=${encodeURIComponent("/music/me")}#account-panel">Sign in</a> with the same email to keep everything under My music.`}</small>
128
+ <small>Payments go through <a href="https://coinpayportal.com">CoinPay</a> to nixamp.com: pick any coin, or PayPal, on CoinPay's page. For small amounts a coin with low fees (USDC on Polygon or Solana, SOL) or PayPal costs least. Listening on the live stations is always free.${me ? "" : ` <a href="/?redirect=${encodeURIComponent("/music/me")}#account-panel">Sign in</a> with the same email to keep everything under My music.`}</small>
137
129
  </div>`, JSON.stringify({ server: serverId(item.server), kind: item.kind, artist: item.artist, album: item.album, n: item.n })), method);
138
130
  return true;
139
131
  }
@@ -148,9 +140,9 @@ export async function handleStoreRoutes(request: IncomingMessage, response: Serv
148
140
  ? `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · paid ${price(purchase.amountCents)}</p>
149
141
  <a class="go" href="${escape(store.zipUrl(purchase))}" download>Download the zip</a>
150
142
  <small>MP3s with the cover art and lyrics, plus each song's .lyrics.txt, cover.jpg and album.json. Keep this page's address (it is in your email too) to download again. Signed in to nixamp.com as ${escape(purchase.email)}, everything you bought is under <a href="/music/me">My music</a>, to play or download.</small></div>`
151
- : `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · ${price(purchase.amountCents)} in ${escape(purchase.coin)}</p>
143
+ : `<div class="card"><h1>${escape(purchase.title)}</h1><p class="by">${escape(purchase.artistName)} · ${price(purchase.amountCents)}${escape(paidIn(purchase.coin))}</p>
152
144
  <p>Waiting for your payment. Once CoinPay confirms it, your download is here and in your email (${escape(purchase.email)}). This page checks again when you reload it.</p>
153
- <a class="go" href="https://coinpayportal.com/pay/${escape(purchase.paymentId)}">Open the payment</a></div>`;
145
+ <a class="go" href="${escape(payUrl(purchase.paymentId))}">Open the payment</a></div>`;
154
146
  html(response, 200, page(purchase.title, body), method);
155
147
  return true;
156
148
  }