diff --git a/system/netlify/functions/gives.mjs b/system/netlify/functions/gives.mjs
index e1e5b936cd..2fb44454ad 100644
--- a/system/netlify/functions/gives.mjs
+++ b/system/netlify/functions/gives.mjs
@@ -12,6 +12,27 @@ const dev = process.env.CONTEXT !== "production";
let cachedGives = null;
let cacheTimestamp = 0;
const CACHE_TTL = 60000; // 1 minute cache
+let subscriberCache = null;
+let subscriberRequest = null;
+
+async function getSubscribers(stripe) {
+ if (subscriberCache && Date.now() - subscriberCache.at < CACHE_TTL) {
+ return subscriberCache.data;
+ }
+ // Share in-flight work across homepage visitors; count beyond Stripe's first page.
+ if (!subscriberRequest) {
+ subscriberRequest = (async () => {
+ let activeSubscribers = 0;
+ for await (const subscription of stripe.subscriptions.list({ status: 'active', limit: 100 })) {
+ activeSubscribers++;
+ }
+ const data = { activeSubscribers, lastUpdated: new Date().toISOString() };
+ subscriberCache = { at: Date.now(), data };
+ return data;
+ })().finally(() => { subscriberRequest = null; });
+ }
+ return subscriberRequest;
+}
export async function handler(event, context) {
// Handle CORS preflight
@@ -23,12 +44,14 @@ export async function handler(event, context) {
return respond(405, { error: "Method not allowed" });
}
+ const subscribersOnly = event.queryStringParameters?.summary === 'subscribers';
+
// Always use production Stripe key for gives feed (show real donations)
const stripeKey = process.env.STRIPE_API_PRIV_KEY;
if (!stripeKey) {
// Return empty gives in dev when Stripe isn't configured
- if (dev) {
+ if (dev && !subscribersOnly) {
return respond(200, { gives: [], total: 0, cached: false, dev: true });
}
return respond(500, { error: "Stripe not configured" });
@@ -36,13 +59,18 @@ export async function handler(event, context) {
// Return cached data if fresh
const now = Date.now();
- if (cachedGives && (now - cacheTimestamp) < CACHE_TTL) {
+ if (!subscribersOnly && cachedGives && (now - cacheTimestamp) < CACHE_TTL) {
return respond(200, cachedGives);
}
const stripe = new Stripe(stripeKey);
try {
+ if (subscribersOnly) {
+ return respond(200, await getSubscribers(stripe), {
+ 'Cache-Control': 'public, max-age=60',
+ });
+ }
const limit = parseInt(event.queryStringParameters?.limit) || 100;
// Only show gives from 2025 onwards (when give page launched)
@@ -165,13 +193,9 @@ export async function handler(event, context) {
}, 0);
// Count active monthly subscribers
- let activeSubscribers = 0;
+ let activeSubscribers = null;
try {
- const subscriptions = await stripe.subscriptions.list({
- status: 'active',
- limit: 100,
- });
- activeSubscribers = subscriptions.data.length;
+ ({ activeSubscribers } = await getSubscribers(stripe));
} catch (e) {
console.log('Could not fetch subscriptions:', e.message);
}
@@ -188,7 +212,7 @@ export async function handler(event, context) {
// Cache the result
cachedGives = result;
- cacheTimestamp = now;
+ cacheTimestamp = Date.now();
return respond(200, result);
} catch (error) {
diff --git a/system/public/aesthetic.computer/disks/prompt.mjs b/system/public/aesthetic.computer/disks/prompt.mjs
index 369de3007e..09f083e6e7 100644
--- a/system/public/aesthetic.computer/disks/prompt.mjs
+++ b/system/public/aesthetic.computer/disks/prompt.mjs
@@ -666,6 +666,8 @@ const KEN_BURNS_MAX_ZOOM = 1.25;
const KEN_BURNS_CYCLE_MS = 8000;
let handles; // Keep track of total handles set.
+let activeSubscribers = null;
+let subscribersBtn;
let motd; // Store the moods of the day text
let motdByHandle; // Store the mood author handle
let motdFrame = 0; // Animation frame counter for MOTD effects (time-based)
@@ -1165,6 +1167,21 @@ async function boot({
// console.log("🧦 Got message:", id, type, content);
});
+ // Fetch the small, cached subscriber summary without donation history.
+ activeSubscribers = null;
+ fetch("/api/gives?summary=subscribers", { signal: AbortSignal.timeout(8000) })
+ .then((res) => {
+ if (!res.ok) throw new Error("Subscriber count unavailable");
+ return res.json();
+ })
+ .then((data) => {
+ if (Number.isSafeInteger(data.activeSubscribers) && data.activeSubscribers >= 0) {
+ activeSubscribers = data.activeSubscribers;
+ needsPaint();
+ }
+ })
+ .catch(() => {}); // Keep the prompt usable when the count is unavailable.
+
// Fetch handle count.
fetch("/handle?count=true")
.then((res) => res.json())
@@ -6243,6 +6260,22 @@ function paint($) {
notepatBtn.btn.disabled = true;
}
+ // A steady link to Give, independent of the rotating promotional slot.
+ if (showLoginCurtain && !$.system.prompt.input.canType &&
+ !$.system.prompt.input.text && activeSubscribers !== null && screen.height >= 280) {
+ const label = `${activeSubscribers.toLocaleString()} monthly subscriber${activeSubscribers === 1 ? "" : "s"}`;
+ const position = { screen, center: "x", y: Math.floor(screen.height / 2 + 65) };
+ if (!subscribersBtn) subscribersBtn = new $.ui.TextButtonSmall(label, position);
+ else subscribersBtn.reposition(position, label);
+ subscribersBtn.btn.disabled = false;
+ subscribersBtn.paint($,
+ [[50, 25, 45], [160, 80, 130], 255, [50, 25, 45]],
+ [[90, 35, 70], [255, 145, 200], 255, [90, 35, 70]],
+ );
+ } else if (subscribersBtn) {
+ subscribersBtn.btn.disabled = true;
+ }
+
// 🎰 Polychrome border effect pointing to top-left corner (on login curtain)
if (showLoginCurtain && flairEnabled) {
const activeProduct = products.getActiveProduct();
@@ -8289,6 +8322,7 @@ function act({
["wallet", walletBtn?.btn],
[promptPromo.label, promoAdBtn?.btn],
["give", giveBtn?.btn],
+ ["subscribers", subscribersBtn?.btn],
["so", soBtn?.btn],
["soft", softBtn?.btn],
["os", osBtn?.btn],
@@ -8682,6 +8716,7 @@ function act({
(signup?.btn.disabled === false && signup?.btn.box.contains(e)) ||
(profile?.btn.disabled === false && profile?.btn.box.contains(e)) ||
(commitBtn?.btn.disabled === false && commitBtn?.btn.box.contains(e)) ||
+ (subscribersBtn?.btn.disabled === false && subscribersBtn?.btn.box.contains(e)) ||
(notepatBtn?.btn.disabled === false && notepatBtn?.btn.box.contains(e)) ||
(kidlispBtn?.btn.disabled === false && kidlispBtn?.btn.box.contains(e)) ||
(clearBtn?.disabled === false && clearBtn?.box.contains(e)) ||
@@ -8702,6 +8737,7 @@ function act({
(clearBtn?.disabled === false && clearBtn?.box.contains(e)) ||
(promoAdBtn?.btn.disabled === false && promoAdBtn?.btn.box.contains(e)) ||
(giveBtn?.btn.disabled === false && giveBtn?.btn.box.contains(e)) ||
+ (subscribersBtn?.btn.disabled === false && subscribersBtn?.btn.box.contains(e)) ||
(soBtn?.btn?.disabled === false && soBtn?.btn?.box.contains(e)) ||
(softBtn?.btn?.disabled === false && softBtn?.btn?.box.contains(e)) ||
(osBtn?.btn?.disabled === false && osBtn?.btn?.box.contains(e)) ||
@@ -8777,6 +8813,20 @@ function act({
}
+ // Subscriber count opens Give.
+ if (subscribersBtn && !subscribersBtn.btn.disabled) {
+ subscribersBtn.btn.act(e, {
+ down: () => downSound(),
+ push: () => {
+ pushSound();
+ const url = "https://give.aesthetic.computer";
+ if (net.iframe) send({ type: "post-to-parent", content: { type: "openExternal", url } });
+ else jump(url);
+ },
+ cancel: () => cancelSound(),
+ });
+ }
+
// 📬 Mail button — straight to the inbox.
if (mailBtn) {
mailBtn.act(e, {
diff --git a/system/public/give.aesthetic.computer/give.mjs b/system/public/give.aesthetic.computer/give.mjs
new file mode 100644
index 0000000000..a014609a98
--- /dev/null
+++ b/system/public/give.aesthetic.computer/give.mjs
@@ -0,0 +1,150 @@
+const $ = (id) => document.getElementById(id);
+const api = 'https://aesthetic.computer/api';
+const params = new URLSearchParams(location.search);
+const prefillEmail = params.get('email');
+if (prefillEmail) {
+ const cleanUrl = new URL(location.href);
+ cleanUrl.searchParams.delete('email');
+ history.replaceState({}, '', cleanUrl);
+}
+// Caddy's language aliases rewrite internally; their query is not visible here.
+const routeLanguage = { da: 'da', de: 'de', es: 'es', cn: 'zh' }[location.pathname.split('/').filter(Boolean).at(-1)];
+const words = {
+ en: { purpose: 'Keeping Aesthetic Computer online and free.', frequency: 'Give', monthly: 'Monthly', once: 'Once', amount: 'Amount', tax: 'Not tax deductible.', bills: 'See the bills', manage: 'Manage subscription', other: 'Other ways to give', subscriber: 'monthly subscriber', subscribers: 'monthly subscribers', give: 'Give', month: 'month', terms: 'Renews monthly. Cancel anytime.', loading: 'Opening checkout…', error: 'Could not open checkout. Please try again.', unavailable: 'Subscriber count unavailable', thanks: 'Thank you for giving!', portalError: 'Could not open your subscription. Log in with your checkout email, or contact mail@aesthetic.computer.', portalLoading: 'Opening subscription…' },
+ da: { purpose: 'Holder Aesthetic Computer online og gratis.', frequency: 'Giv', monthly: 'Månedligt', once: 'Én gang', amount: 'Beløb', tax: 'Ikke fradragsberettiget.', bills: 'Se regningerne', manage: 'Administrer abonnement', other: 'Andre måder at give', subscriber: 'månedlig abonnent', subscribers: 'månedlige abonnenter', give: 'Giv', month: 'måned', terms: 'Fornyes månedligt. Opsig når som helst.', loading: 'Åbner betaling…', error: 'Kunne ikke åbne betaling. Prøv igen.', unavailable: 'Antal abonnenter utilgængeligt', thanks: 'Tak for dit bidrag!', portalError: 'Kunne ikke åbne dit abonnement. Log ind med din betalingsmail, eller kontakt mail@aesthetic.computer.', portalLoading: 'Åbner abonnement…' },
+ de: { purpose: 'Hält Aesthetic Computer online und kostenlos.', frequency: 'Geben', monthly: 'Monatlich', once: 'Einmalig', amount: 'Betrag', tax: 'Nicht steuerlich absetzbar.', bills: 'Kosten ansehen', manage: 'Abo verwalten', other: 'Weitere Zahlungsarten', subscriber: 'monatlicher Unterstützer', subscribers: 'monatliche Unterstützer', give: 'Geben', month: 'Monat', terms: 'Monatliche Verlängerung. Jederzeit kündbar.', loading: 'Zahlung wird geöffnet…', error: 'Zahlung konnte nicht geöffnet werden. Bitte erneut versuchen.', unavailable: 'Anzahl nicht verfügbar', thanks: 'Vielen Dank für deinen Beitrag!', portalError: 'Abo konnte nicht geöffnet werden. Mit der Zahlungs-E-Mail anmelden oder mail@aesthetic.computer kontaktieren.', portalLoading: 'Abo wird geöffnet…' },
+ es: { purpose: 'Manteniendo Aesthetic Computer en línea y gratuito.', frequency: 'Dar', monthly: 'Mensual', once: 'Una vez', amount: 'Cantidad', tax: 'No deducible de impuestos.', bills: 'Ver los gastos', manage: 'Gestionar suscripción', other: 'Otras formas de dar', subscriber: 'suscriptor mensual', subscribers: 'suscriptores mensuales', give: 'Dar', month: 'mes', terms: 'Se renueva cada mes. Cancela cuando quieras.', loading: 'Abriendo pago…', error: 'No se pudo abrir el pago. Inténtalo de nuevo.', unavailable: 'Recuento no disponible', thanks: '¡Gracias por tu aportación!', portalError: 'No se pudo abrir tu suscripción. Inicia sesión con tu correo de pago o contacta con mail@aesthetic.computer.', portalLoading: 'Abriendo suscripción…' },
+ zh: { purpose: '让 Aesthetic Computer 保持在线和免费。', frequency: '捐赠', monthly: '每月', once: '一次', amount: '金额', tax: '不可抵税。', bills: '查看账单', manage: '管理订阅', other: '其他捐赠方式', subscriber: '位每月订阅者', subscribers: '位每月订阅者', give: '捐赠', month: '月', terms: '每月自动续订,可随时取消。', loading: '正在打开付款页面…', error: '无法打开付款页面,请重试。', unavailable: '订阅人数暂不可用', thanks: '感谢您的捐赠!', portalError: '无法打开订阅。请使用付款邮箱登录,或联系 mail@aesthetic.computer。', portalLoading: '正在打开订阅…' },
+};
+let lang = params.get('lang') || routeLanguage || navigator.language.split('-')[0];
+if (!Object.hasOwn(words, lang)) lang = 'en';
+$('language').value = lang;
+let count = null;
+let countFailed = false;
+let busy = false;
+const monthly = () => $('form').elements.frequency.value === 'monthly';
+const format = (amount) => new Intl.NumberFormat(lang, { style: 'currency', currency: $('currency').value.toUpperCase(), currencyDisplay: 'narrowSymbol', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(amount);
+
+function render() {
+ const t = words[lang];
+ document.documentElement.lang = lang;
+ document.querySelectorAll('[data-text]').forEach((el) => { el.textContent = t[el.dataset.text]; });
+ $('subscriber-label').textContent = countFailed ? t.unavailable : count === 1 ? t.subscriber : t.subscribers;
+ $('count').textContent = count === null ? '—' : count.toLocaleString(lang);
+ $('count').setAttribute('aria-label', count === null ? t.unavailable : `${count} ${count === 1 ? t.subscriber : t.subscribers}`);
+ $('give').textContent = busy ? t.loading : `${t.give} ${format(Number($('amount').value) || 0)}${monthly() ? ` / ${t.month}` : ''}`;
+ $('terms').textContent = monthly() ? t.terms : '';
+ $('thanks').textContent = t.thanks;
+ document.querySelectorAll('[data-amount]').forEach((button) => { button.textContent = format(Number(button.dataset.amount)); });
+}
+
+$('language').addEventListener('change', () => { lang = $('language').value; render(); });
+$('form').addEventListener('input', render);
+function updateCurrency() {
+ const dkk = $('currency').value === 'dkk';
+ $('amount').min = dkk ? '5' : '1';
+ $('amount').max = dkk ? '17500' : '2500';
+ $('amount').value = dkk ? '50' : '8';
+ document.querySelectorAll('[data-amount]').forEach((button, i) => { button.dataset.amount = (dkk ? [25, 50, 100, 200] : [4, 8, 16, 32])[i]; });
+ render();
+}
+$('currency').addEventListener('change', updateCurrency);
+const initialCurrency = params.get('currency') || (lang === 'da' ? 'dkk' : 'usd');
+$('currency').value = initialCurrency === 'dkk' ? 'dkk' : 'usd';
+updateCurrency();
+$('presets').addEventListener('click', (event) => {
+ const button = event.target.closest('[data-amount]');
+ if (button) { $('amount').value = button.dataset.amount; render(); }
+});
+$('thanks').hidden = params.get('thanks') !== '1';
+render();
+
+fetch(`${api}/gives?summary=subscribers`, { signal: AbortSignal.timeout(8000) })
+ .then(async (res) => {
+ if (!res.ok) throw new Error('Count unavailable');
+ const data = await res.json();
+ if (!Number.isSafeInteger(data.activeSubscribers) || data.activeSubscribers < 0) throw new Error('Invalid count');
+ count = data.activeSubscribers;
+ })
+ .catch(() => { countFailed = true; })
+ .finally(render);
+
+$('form').addEventListener('submit', async (event) => {
+ event.preventDefault();
+ if (busy || !$('form').reportValidity()) return;
+ busy = true;
+ $('give').disabled = true;
+ $('error').textContent = '';
+ render();
+ try {
+ const payload = { amount: Math.round(Number($('amount').value) * 100), currency: $('currency').value, recurring: monthly() };
+ if (prefillEmail) payload.email = prefillEmail;
+ const res = await fetch(`${api}/give`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload), signal: AbortSignal.timeout(20000),
+ });
+ const data = await res.json();
+ if (!res.ok || !data.url) throw new Error('Checkout unavailable');
+ const url = new URL(data.url);
+ if (url.protocol !== 'https:' || url.hostname !== 'checkout.stripe.com') throw new Error('Invalid checkout URL');
+ location.assign(url.href);
+ } catch {
+ $('error').textContent = words[lang].error;
+ busy = false;
+ $('give').disabled = false;
+ render();
+ }
+});
+
+// Account tools are only loaded when managing an existing subscription.
+let authPromise;
+function getAuth() {
+ if (!authPromise) authPromise = (async () => {
+ await new Promise((resolve, reject) => {
+ const script = document.createElement('script');
+ script.src = 'https://cdn.auth0.com/js/auth0-spa-js/2.0/auth0-spa-js.production.js';
+ script.onload = resolve;
+ script.onerror = () => { script.remove(); reject(new Error('Could not load login')); };
+ document.head.append(script);
+ });
+ return window.auth0.createAuth0Client({
+ domain: 'aesthetic.us.auth0.com', clientId: 'LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt',
+ authorizationParams: { redirect_uri: location.origin + location.pathname, audience: 'https://aesthetic.us.auth0.com/api/v2/' },
+ cacheLocation: 'localstorage', useRefreshTokens: true, useRefreshTokensFallback: true,
+ });
+ })().catch((error) => { authPromise = null; throw error; });
+ return authPromise;
+}
+async function manage(callback = false) {
+ $('manage').disabled = true;
+ $('error').textContent = '';
+ $('status').textContent = words[lang].portalLoading;
+ try {
+ const auth = await getAuth();
+ if (callback) {
+ await auth.handleRedirectCallback();
+ history.replaceState({}, '', location.pathname);
+ }
+ if (!await auth.isAuthenticated()) {
+ await auth.loginWithRedirect();
+ return;
+ }
+ const token = await auth.getTokenSilently();
+ const res = await fetch(`${api}/give-portal`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
+ body: '{}', signal: AbortSignal.timeout(20000),
+ });
+ const data = await res.json();
+ if (!res.ok || !data.url) throw new Error('Portal unavailable');
+ const url = new URL(data.url);
+ if (url.protocol !== 'https:' || url.hostname !== 'billing.stripe.com') throw new Error('Invalid portal URL');
+ location.assign(url.href);
+ } catch {
+ $('error').textContent = words[lang].portalError;
+ } finally {
+ $('manage').disabled = false;
+ $('status').textContent = '';
+ }
+}
+$('manage').addEventListener('click', () => manage());
+if (params.has('state') && (params.has('code') || params.has('error'))) manage(true);
diff --git a/system/public/give.aesthetic.computer/index.html b/system/public/give.aesthetic.computer/index.html
index 9a16690a04..b55c376d23 100644
--- a/system/public/give.aesthetic.computer/index.html
+++ b/system/public/give.aesthetic.computer/index.html
@@ -1,14471 +1,106 @@
-
-
+
+
+
+
Give · Aesthetic Computer
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
give — Aesthetic. Computer '26
-
-
-
English
-
▼
-
-
English
-
Dansk
-
Deutsch
-
Español
-
中文
-
-
-
-
- 💵 USD
- 💶 DKK
- 🔗 CRYPTO
- 🅿️ PAYPAL
- 🪙 LIBERAPAY
-
-
-
-
-
-
-
-
-
-
- TY for keeping systems online and free to use!
-
-
- Tak for at give $25 og holde Aesthetic.Computer online og gratis at bruge.
-
-
- Danke für Ihre Spende von $25 . Damit bleibt Aesthetic.Computer online und kostenlos.
-
-
- Gracias por donar $25 y mantener Aesthetic.Computer en línea y gratuito.
-
-
- 感谢您捐赠$25 ,让Aesthetic.Computer保持在线并免费使用。
-
-
-
-
-
-
-
-
-
- 0
- gives
-
-
-
- 0
- monthly
-
-
-
-
-
-
-
-
-
Aesthetic. Computer is a social software network supported by the full-time programming practice of
@jeffrey who has authored new media interfaces for 15 years. He lives in Echo Park with
@fifi in a house full of musicians and regularly does show and tell at the
NELA Computer Club in Chinatown.
-
-
-
Aesthetic. Computer er et socialt softwarenetværk understøttet af fuldtids ingeniørpraksis af
@jeffrey , som har bygget eksperimentelle software-instrumenter i over et årti. Han bor i Echo Park med
@fifi i et hus fuldt af musikere, og demonstrerer regelmæssigt softwaren ved
NELA Computer Club i Chinatown.
-
-
-
Aesthetic. Computer ist ein soziales Software-Netzwerk, unterstützt durch die Vollzeit-Ingenieurpraxis von
@jeffrey , der seit über einem Jahrzehnt experimentelle Software-Instrumente baut. Er lebt in Echo Park mit
@fifi in einem Haus voller Musiker und präsentiert regelmäßig beim
NELA Computer Club in Chinatown.
-
-
-
Aesthetic. Computer es una red de software social apoyada por la práctica de ingeniería a tiempo completo de
@jeffrey , quien ha estado creando instrumentos de software experimentales durante más de una década. Vive en Echo Park con
@fifi en una casa llena de músicos, y regularmente hace demostraciones en el
NELA Computer Club en Chinatown.
-
-
-
-
- 🇩🇰
Goodiepal og
laer-klokken -fællesskabet har brugt softwaren hver dag til at dyrke fællesskab i Københavns IRL musik-, kunst- og kulturscene.
-
-
-
-
Why give?
-
Hvorfor give?
-
Warum geben?
-
¿Por qué dar?
-
为什么捐赠?
-
-
Because we publish transparent costs. See
Bills to track where support goes.
-
Fordi vi viser gennemsigtige omkostninger. Se
Bills for at følge hvor støtten går hen.
-
Weil wir unsere Kosten transparent veröffentlichen. Unter
Bills sehen Sie, wohin die Unterstützung geht.
-
Porque publicamos costos transparentes. En
Bills puedes ver a dónde va el apoyo.
-
因为我们公开透明地展示成本。查看
Bills ,了解支持资金的去向。
-
-
-
-
-
-
—
-
- @ handles
- @ brugernavne
- @ Benutzer
- @ usuarios
- @ 用户名
-
-
-
-
-
—
-
- # paintings
- # malerier
- # Bilder
- # pinturas
- # 绘画
-
-
-
-
-
—
-
- moods
- stemninger
- Stimmungen
- estados
- 心情
-
-
-
-
-
—
-
- $ kidlisps
- $ kidlisps
- $ kidlisps
- $ kidlisps
- $ kidlisps
-
-
-
-
-
—
-
- commands
- kommandoer
- Befehle
- comandos
- 命令
-
-
-
-
-
—
-
- chats
- chats
- Chats
- chats
- 聊天
-
-
-
-
-
-
-
-
-
-
- A new programming language for gen beta
- Et nyt programmeringssprog for gen beta
- Eine neue Programmiersprache für Gen Beta
- Un nuevo lenguaje de programación para gen beta
- 为gen beta设计的新编程语言
-
-
-
-
-
-
-
-
-
-
-
-
-
- Prefix a prompt with tape to record it
- Sæt tape før en prompt for at optage
- Setze tape vor einen Prompt zum Aufnehmen
- Prefija un prompt con tape para grabarlo
- 在提示前加 tape 即可录制
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Desktop apps and plugins are free
- Desktop-apps og plugins er gratis
- Desktop-Apps und Plugins sind kostenlos
- Las apps de escritorio y plugins son gratis
- 桌面应用和插件免费
-
-
-
-
-
-
-
-
-
-
- Mobile app is free on iOS
- Mobil-appen er gratis på iOS
- Die mobile App ist kostenlos auf iOS
- La app móvil es gratis en iOS
- 移动应用在iOS上免费
-
-
-
-
-
-
-
-
-
-
-
-
- Get books, bikes, artworks and more
- Få bøger, cykler, kunstværker og mere
- Holen Sie sich Bücher, Fahrräder, Kunstwerke und mehr
- Obtén libros, bicicletas, obras de arte y más
- 获取书籍、自行车、艺术品等
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ↑ Back to Top
- ↑ Tilbage til top
- ↑ Nach oben
- ↑ Volver arriba
- ↑ 返回顶部
-
-
+
+
+ —
+ monthly subscribers
+ Keeping Aesthetic Computer online and free.
+
+ JavaScript is required for card checkout. You can also give through the links below.
+
-
-
-
-
- Log in for Monthly
- Log ind for månedligt
- Anmelden für Monatlich
- Iniciar sesión para Mensual
- 登录以订阅
-
-
- Log in first so we can link your subscription to your account.
- Log ind først, så vi kan tilknytte dit abonnement til din konto.
- Melden Sie sich an, damit wir Ihr Abonnement mit Ihrem Konto verknüpfen können.
- Inicia sesión primero para vincular tu suscripción a tu cuenta.
- 请先登录,以便将您的订阅关联到您的账户。
-
-
-
- Not now
- Ikke nu
- Nicht jetzt
- Ahora no
- 暂不
-
-
- Log in
- Log ind
- Anmelden
- Iniciar sesión
- 登录
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/tests/browser/give.test.mjs b/tests/browser/give.test.mjs
new file mode 100644
index 0000000000..b0bfcef6f1
--- /dev/null
+++ b/tests/browser/give.test.mjs
@@ -0,0 +1,123 @@
+// Local Give UI checks. Stripe and subscriber responses are mocked; no payments are created.
+// Run: node tests/browser/give.test.mjs (uses installed Chrome).
+import { chromium } from 'playwright';
+import { readFile, mkdtemp } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createServer } from 'node:http';
+import assert from 'node:assert/strict';
+const root = fileURLToPath(new URL('../../system/public/give.aesthetic.computer', import.meta.url));
+const shots = await mkdtemp(join(tmpdir(), 'ac-give-review-'));
+console.log('Screenshots:', shots);
+const server = createServer(async (req, res) => {
+ try {
+ const name = new URL(req.url, 'http://localhost').pathname;
+ res.setHeader('Content-Type', name.endsWith('.mjs') ? 'text/javascript' : name.endsWith('.svg') ? 'image/svg+xml' : 'text/html');
+ const alias = ['/', '/da', '/de', '/es', '/cn'].includes(name);
+ res.end(await readFile(root + (alias ? '/index.html' : name)));
+ } catch { res.writeHead(404).end(); }
+});
+await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+const base = `http://127.0.0.1:${server.address().port}`;
+let browser;
+try {
+ browser = await chromium.launch({headless:true, channel:process.env.AC_BROWSER_CHANNEL || 'chrome'});
+ const page = await browser.newPage({viewport:{width:1200,height:1000}, colorScheme:'dark'});
+ const errors = []; page.on('pageerror', error=>errors.push(error.message));
+ let count = 6, countError = false;
+ await page.route('**/api/gives?summary=subscribers', route => route.fulfill({status:countError?503:200, json:{activeSubscribers:count}}));
+ let payloads=[];
+ await page.route('**/api/give', route => { payloads.push(route.request().postDataJSON()); return route.fulfill({status:503,json:{error:'Unavailable'}}); });
+ const requests=[]; page.on('request', req=>requests.push(req.url()));
+ await page.goto(base);
+ await page.waitForFunction(()=>document.querySelector('#count').textContent==='6');
+ await page.screenshot({path:join(shots, 'desktop.png'),fullPage:true});
+ assert.equal(await page.locator('#give').textContent(),'Give $8 / month');
+ await page.locator('#give').click();
+ await page.locator('#error').waitFor({state:'visible'});
+ assert.deepEqual(payloads.at(-1),{amount:800,currency:'usd',recurring:true});
+ await page.locator('input[value="once"]').check();
+ await page.locator('#amount').fill('12.34');
+ await page.locator('#give').click();
+ await page.waitForFunction(()=>!document.querySelector('#give').disabled);
+ assert.deepEqual(payloads.at(-1),{amount:1234,currency:'usd',recurring:false});
+ await page.locator('#currency').selectOption('dkk');
+ await page.locator('#give').click();
+ await page.waitForFunction(()=>!document.querySelector('#give').disabled);
+ assert.deepEqual(payloads.at(-1),{amount:5000,currency:'dkk',recurring:false});
+ const calls = payloads.length;
+ await page.locator('#amount').fill('0'); await page.locator('#give').click();
+ assert.equal(payloads.length,calls);
+ await page.setViewportSize({width:375,height:812});
+ await page.goto(base); await page.waitForFunction(()=>document.querySelector('#count').textContent==='6');
+ await page.screenshot({path:join(shots, 'mobile.png'),fullPage:true});
+ for(const lang of ['en','da','de','es','zh']) {
+ await page.locator('#language').selectOption(lang);
+ assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true,lang+' overflow');
+ }
+ await page.setViewportSize({width:320,height:700});
+ for (const currency of ['usd','dkk']) {
+ await page.locator('#currency').selectOption(currency);
+ for (const lang of ['en','da','de','es','zh']) {
+ await page.locator('#language').selectOption(lang);
+ assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true,`${lang}/${currency} at 320px`);
+ }
+ }
+ await page.setViewportSize({width:375,height:812});
+ await page.locator('#currency').selectOption('usd');
+ await page.emulateMedia({colorScheme:'light'}); await page.locator('#language').selectOption('en');
+ await page.screenshot({path:join(shots, 'light.png'),fullPage:true});
+ count=0; await page.reload(); await page.waitForFunction(()=>document.querySelector('#count').textContent==='0');
+ count=1; await page.reload(); await page.waitForFunction(()=>document.querySelector('#subscriber-label').textContent==='monthly subscriber');
+ countError=true; await page.reload(); await page.waitForFunction(()=>document.querySelector('#subscriber-label').textContent==='Subscriber count unavailable');
+ assert.equal(await page.locator('#count').textContent(),'—');
+ assert.equal(await page.locator('#give').isEnabled(),true);
+ await page.goto(base+'/thanks.html?amount=8¤cy=usd');
+ await page.locator('#thanks').waitFor({state:'visible'});
+ assert.deepEqual(errors,[]);
+ assert.equal(requests.some(url=>/auth0|fonts|tv\?|chat|coingecko/.test(url)),false);
+ for (const [path, lang, currency] of [['da','da','dkk'], ['de','de','usd'], ['es','es','usd'], ['cn','zh','usd']]) {
+ await page.goto(`${base}/${path}`);
+ await page.waitForFunction((lang)=>document.documentElement.lang===lang, lang);
+ assert.equal(await page.locator('#currency').inputValue(),currency);
+ }
+ await page.goto(base+'/?lang=toString¤cy=eur');
+ await page.waitForFunction(()=>document.querySelector('#subscriber-label').textContent==='Subscriber count unavailable');
+ assert.equal(await page.locator('html').getAttribute('lang'),'en');
+ assert.equal(await page.locator('#currency').inputValue(),'usd');
+ await page.goto(base+'/?email=donor%40example.com¤cy=dkk');
+ await page.waitForFunction(()=>!location.search.includes('email'));
+ await page.locator('#give').click();
+ await page.waitForFunction(()=>!document.querySelector('#give').disabled);
+ assert.deepEqual(payloads.at(-1),{amount:5000,currency:'dkk',recurring:true,email:'donor@example.com'});
+ assert.deepEqual(errors,[]);
+ console.log('PASS: language aliases, currency links, invalid language fallback, private email prefill.');
+ // Auth loads only on demand; exercise both portal access and login redirect.
+ await page.route('https://cdn.auth0.com/**', route=>route.fulfill({contentType:'text/javascript',body:`
+ window.auth0 = { createAuth0Client: async () => ({
+ isAuthenticated: async () => !new URLSearchParams(location.search).has('signedOut'),
+ getTokenSilently: async () => 'test-token',
+ handleRedirectCallback: async () => { window.callbackHandled = true; },
+ loginWithRedirect: async () => { window.loginRequested = true; }
+ }) };
+ `}));
+ let authorization;
+ await page.route('**/api/give-portal', route=> {
+ authorization = route.request().headers().authorization;
+ return route.fulfill({json:{url:'https://billing.stripe.com/p/session/test-only'}});
+ });
+ await page.route('https://billing.stripe.com/**',route=>route.fulfill({body:'Portal stub'}));
+ await page.goto(base);
+ await page.locator('#manage').click(); await page.waitForURL('https://billing.stripe.com/**');
+ assert.equal(authorization,'Bearer test-token');
+ await page.goto(base+'/?code=test&state=test'); await page.waitForURL('https://billing.stripe.com/**');
+ await page.goto(base+'/?signedOut=1'); await page.locator('#manage').click();
+ await page.waitForFunction(()=>window.loginRequested===true);
+ console.log('PASS: on-demand subscription portal, authenticated callback, and signed-out login redirect.');
+ // Verify checkout redirect without creating a real Stripe session.
+ await page.route('**/api/give', route=>route.fulfill({json:{url:'https://checkout.stripe.com/c/pay/test-only'}}));
+ await page.route('https://checkout.stripe.com/**',route=>route.fulfill({body:'Checkout stub'}));
+ await page.locator('#give').click(); await page.waitForURL('https://checkout.stripe.com/**');
+ console.log('PASS: mobile + five languages, dark/light, USD/DKK, monthly/once, cents, invalid amount, errors, zero/singular counts, thanks redirect, checkout redirect; no eager external libraries.');
+} finally { await browser?.close(); server.close(); }