From cbe930ec82ba7de8aca2dc22ebeb00b12964980a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 30 Sep 2026 16:49:12 -0700 Subject: [PATCH] Simplify Give and show subscriber count on prompt --- system/netlify/functions/gives.mjs | 42 +- .../aesthetic.computer/disks/prompt.mjs | 50 + .../public/give.aesthetic.computer/give.mjs | 150 + .../public/give.aesthetic.computer/index.html | 14555 +--------------- tests/browser/give.test.mjs | 123 + 5 files changed, 451 insertions(+), 14469 deletions(-) create mode 100644 system/public/give.aesthetic.computer/give.mjs create mode 100644 tests/browser/give.test.mjs 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 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + - - - - - -
-
- -
- - English - ▼ -
-
English
-
Dansk
-
Deutsch
-
Español
-
中文
-
-
-
- -
-
-
- -
-
- -
-

- 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保持在线并免费使用。 -

-
- Give again? - Giv igen? - Nochmal spenden? - ¿Donar de nuevo? - 再次捐赠? - - - - - -
-
- - -
-
-
-
- 0 - gives - -
-
- 0 - monthly -
-
-
-
-
Loading gives...
-
-
-
-
-
-
-
-
-
- -
$128
-
-
-
- - - -
-
- - - Cancel? - Annuller? - Kündigen? - ¿Cancelar? - 取消? - -
- - Not tax deductible. Investor? - Ikke fradragsberettiget. Investor? - Nicht steuerlich absetzbar. Investor? - No deducible de impuestos. ¿Inversor? - 不可抵税。投资者? - -
-
-
-
- - - - -
-
- -
- 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. -
-
- Aesthetic.Computer 是一个社交软件网络,由 @jeffrey 的全职工程实践所支持,他已经创建实验性软件工具超过十年。他与 @fifi 住在 Echo Park 一个满是音乐人的房子里,并定期在 Chinatown 的 NELA Computer Club 展示。 -
- -
- 🇩🇰 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设计的新编程语言 -
-
- -
-
- -
-
- 🕐 laer-klokken -
-
-
Loading...
-
-
- @prutti's weekly r8Dio show LIVE from CPH - @pruttis ugentlige r8Dio show LIVE fra KBH - @pruttis wöchentliche r8Dio Show LIVE aus KPH - @prutti show semanal de r8Dio EN VIVO desde CPH - @prutti的每周r8Dio节目从哥本哈根直播 -
-
-
-
-
- - -
-
- 💬 chat -
-
-
Loading...
-
-
- Get a @handle and come say hi - Få en @handle og kom og sig hej - Hol dir einen @handle und sag Hallo - Obtén un @handle y ven a saludar - 获取@handle来打个招呼吧 -
-
-
-
-
-
-
- - - -
-
-
-
- 📼 tapes -
-
-
loading
-
-
- 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 - skrivebord - Desktop - escritorio - 桌面 - -
-
- Loading changelog... -
-
-
- - - - -
- 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 - mobil - Mobil - móvil - 移动 - -
- - -
- 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上免费 -
-
-
-
-
-
-
- - - -
-
- 📈 - Invest - Investér - Investieren - Invertir - 投资 -
-
-

Aesthetic Inc. was booted by a handful of early supporters in 2025.

To join this equity holding community or increase your existing stake, book a remote demo with @jeffrey.

-
-
-

Aesthetic Inc. bliver startet af en håndfuld tidlige støtter, der deler i at reservere virksomhedskapital.

For at blive en del af dette fællesskab, book en fjern-demo med @jeffrey.

-
-
-

Aesthetic Inc. wurde von einer Handvoll früher Unterstützer gestartet.

Um dieser Investoren-Community beizutreten, buchen Sie eine Demo mit @jeffrey.

-
-
-

Aesthetic Inc. fue iniciada por un puñado de primeros partidarios.

Para unirte a esta comunidad de inversores, reserva una demo remota con @jeffrey.

-
-
-

Aesthetic Inc. 由一小群早期支持者启动。

要加入这个股权持有社区,请与 @jeffrey 预约远程演示。

-
- - Mug preview - - white mug of CODE in $give - -
- - - - - - - -
- - -
- ↑ Back to Top - ↑ Tilbage til top - ↑ Nach oben - ↑ Volver arriba - ↑ 返回顶部 -
-
+
+ Aesthetic Computer + +
+ +

—

+

monthly subscribers

+

Keeping Aesthetic Computer online and free.

+
+
+ Give +
+ + +
+
+ +
+ + +
+
+ +
+ + +

+

Renews monthly. Cancel anytime. Not tax deductible.

+
+ +
+ +
+ Other ways to give + +

Tezos
tz1gkf8EexComFBJvjtT1zdsisdah791KwBE

+

Ethereum
0x1D64E3eFa983D945cBFe29Ad5b3C8ABB53Aef023

+

Bitcoin
bc1q699gnqr92gvgv62nla82typpj3wls5a8xf59as

+
+
- - - - - - - - - - - - - - - - - - + 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(); } -- 2.51.2