diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 9b23004..35e5421 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -36,3 +36,5 @@ jobs: run: npm run build - name: Verify production routes and admin sessions run: npm run test:smoke + - name: Verify support reward table + run: npm run test:pricing diff --git a/package.json b/package.json index 1d84209..2eaca1f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "test:smoke": "node --test tests/runtime.test.mjs" + "test:smoke": "node --test tests/runtime.test.mjs", + "test:pricing": "node --experimental-strip-types --no-warnings --test tests/support-pricing.test.mjs" }, "dependencies": { "@vercel/analytics": "^2.0.1", diff --git a/public/support/atomic-catalyst-cover.webp b/public/support/atomic-catalyst-cover.webp new file mode 100644 index 0000000..ce7b636 Binary files /dev/null and b/public/support/atomic-catalyst-cover.webp differ diff --git a/public/support/atomic-reactor-cover.webp b/public/support/atomic-reactor-cover.webp new file mode 100644 index 0000000..ce5a207 Binary files /dev/null and b/public/support/atomic-reactor-cover.webp differ diff --git a/public/support/atomic-spark-cover.webp b/public/support/atomic-spark-cover.webp new file mode 100644 index 0000000..be38407 Binary files /dev/null and b/public/support/atomic-spark-cover.webp differ diff --git a/public/support/atomic-welcome.gif b/public/support/atomic-welcome.gif new file mode 100644 index 0000000..0ffeb91 Binary files /dev/null and b/public/support/atomic-welcome.gif differ diff --git a/src/app/globals.css b/src/app/globals.css index a96a6de..d6c6773 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2837,3 +2837,851 @@ section h2 { transform: none; } } + +/* ---------- support reactor ---------- */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.support-promise { + font-family: var(--mono); + text-transform: uppercase; + letter-spacing: 1.6px; + font-size: 0.74rem; + color: var(--signal); + margin: 14px 0 24px; +} +#reactor, +#monthly, +#how, +#faq { + scroll-margin-top: 72px; +} +.rx-section { + background-color: var(--ink); + background-image: radial-gradient(rgba(244, 245, 241, 0.07) 1.2px, transparent 1.3px); + background-size: 22px 22px; +} +.rx-intro { + max-width: 56ch; + margin-bottom: 30px; +} +.rx-grid { + display: grid; + grid-template-columns: minmax(0, 58fr) minmax(0, 42fr); + border: 1px solid rgba(244, 245, 241, 0.16); + border-radius: 6px; + background: rgba(21, 23, 27, 0.72); +} +.reactor { + padding: 26px 28px 30px; + border-right: 1px solid rgba(244, 245, 241, 0.16); + min-width: 0; +} +.reactor-head, +.reactor-scale { + display: flex; + justify-content: space-between; + gap: 12px; + font-family: var(--mono); + font-size: 0.68rem; + letter-spacing: 1.6px; + text-transform: uppercase; +} +.reactor-head p { + margin: 0; +} +.reactor-label { + color: #8f88ff; +} +.reactor-tag { + color: rgba(244, 245, 241, 0.6); +} +.reactor.is-full .reactor-tag { + color: #ffa53c; +} + +.reactor-dial { + position: relative; + width: min(100%, 400px); + aspect-ratio: 1; + margin: 14px auto 6px; + container-type: inline-size; +} +.reactor-dial svg { + display: block; + width: 100%; + height: 100%; + overflow: visible; +} +.rx-seg { + fill: none; + stroke: rgba(244, 245, 241, 0.14); + stroke-width: 9; + transition: stroke 0.18s ease; +} +.rx-seg.member:not(.on) { + stroke: rgba(143, 136, 255, 0.38); +} +.rx-seg.on { + stroke: #4b40ff; +} +.reactor.is-full .rx-seg.on:nth-of-type(10) { + stroke: #ff8a00; +} +.rx-ring { + fill: none; + stroke: rgba(143, 136, 255, 0.4); + stroke-width: 1.5; + stroke-dasharray: 4 9; + transform-box: view-box; + transform-origin: 50% 50%; + animation: rx-spin 26s linear infinite reverse; +} +.rx-atom { + transform-box: view-box; + transform-origin: 50% 50%; + animation: rx-spin 22s linear infinite; +} +.rx-orbit { + fill: none; + stroke: #dedaff; + stroke-width: 3; + opacity: 0.92; +} +.reactor.is-full .rx-orbit { + stroke: #fff4e6; +} +.rx-electron { + fill: #ffffff; +} +.reactor.is-full .rx-electron { + fill: #ffb040; +} +.rx-core { + transform-box: fill-box; + transform-origin: 50% 50%; + animation: + rx-core-in 0.5s ease-out, + rx-breathe 4.5s ease-in-out 0.5s infinite; +} +.rx-spark { + position: absolute; + left: 50%; + top: 50%; + width: 12px; + height: 12px; + margin: -6px 0 0 -6px; + border-radius: 50%; + background: #fff; + box-shadow: + 0 0 10px 3px rgba(143, 136, 255, 0.9), + 0 0 22px 6px rgba(58, 47, 240, 0.6); + opacity: 0; + pointer-events: none; + animation: rx-spark 0.62s ease-in forwards; +} +.reactor.is-full .rx-spark { + box-shadow: + 0 0 10px 3px rgba(255, 176, 64, 0.95), + 0 0 24px 8px rgba(255, 138, 0, 0.55); +} +.reactor-count { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + pointer-events: none; + text-align: center; +} +.rx-disc { + fill: #15171b; + stroke: #4b40ff; + stroke-width: 3; +} +.reactor.is-full .rx-disc { + stroke: #ff8a00; +} +.reactor-num { + font-family: var(--display); + font-size: clamp(2.6rem, 15cqw, 4rem); + line-height: 0.9; + color: #ffffff; + font-variant-numeric: tabular-nums; +} +.reactor-unit { + margin-top: 4px; + max-width: 28cqw; + font-family: var(--mono); + font-size: clamp(0.5rem, 2.4cqw, 0.6rem); + letter-spacing: 1.2px; + line-height: 1.3; + color: #a9a2ff; + font-weight: 500; +} +.reactor.is-full .reactor-unit { + color: #ffa53c; +} + +.reactor-slider { + margin-top: 10px; +} +.reactor-slider input[type="range"] { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 34px; + background: transparent; + cursor: pointer; + margin: 0; +} +.reactor-slider input[type="range"]::-webkit-slider-runnable-track { + height: 10px; + border-radius: 5px; + background: linear-gradient(to right, #4b40ff var(--fill), rgba(244, 245, 241, 0.16) var(--fill)); +} +.reactor-slider input[type="range"]::-moz-range-track { + height: 10px; + border-radius: 5px; + background: linear-gradient(to right, #4b40ff var(--fill), rgba(244, 245, 241, 0.16) var(--fill)); +} +.reactor-slider input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 28px; + height: 28px; + margin-top: -9px; + border-radius: 50%; + background: #f4f5f1; + border: 4px solid #3a2ff0; + box-shadow: 0 0 0 4px rgba(58, 47, 240, 0.25); +} +.reactor-slider input[type="range"]::-moz-range-thumb { + width: 20px; + height: 20px; + border-radius: 50%; + background: #f4f5f1; + border: 4px solid #3a2ff0; +} +.reactor-slider input[type="range"]:focus-visible { + outline: 2px solid #f4f5f1; + outline-offset: 4px; + border-radius: 6px; +} +.reactor-scale { + color: rgba(244, 245, 241, 0.55); + margin-top: 2px; +} + +.reactor-presets, +.reactor-mode { + display: grid; + gap: 10px; + margin-top: 16px; +} +.reactor-presets { + grid-template-columns: repeat(3, 1fr); +} +.reactor-mode { + grid-template-columns: 1fr 1fr; + margin-top: 10px; +} +.reactor-presets button, +.reactor-mode button { + font-family: var(--mono); + text-transform: uppercase; + letter-spacing: 1.4px; + font-size: 0.78rem; + padding: 12px 10px; + border-radius: 4px; + border: 1.5px solid rgba(244, 245, 241, 0.3); + background: transparent; + color: #f4f5f1; + cursor: pointer; + transition: + background 0.15s ease, + color 0.15s ease, + border-color 0.15s ease, + transform 0.12s ease; +} +.reactor-mode button { + font-size: 0.68rem; + padding: 10px; +} +.reactor-presets button { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + padding: 10px 8px; +} +.rp-price { + font-size: 0.9rem; +} +.rp-name { + font-size: 0.58rem; + letter-spacing: 1.4px; + opacity: 0.75; +} +.reactor-presets button:hover, +.reactor-mode button:hover { + border-color: #f4f5f1; +} +.reactor-presets button:active, +.reactor-mode button:active { + transform: translateY(1px); +} +.reactor-presets button[aria-pressed="true"], +.reactor-mode button[aria-checked="true"] { + background: #f4f5f1; + color: #15171b; + border-color: #f4f5f1; +} +.reactor-presets button:focus-visible, +.reactor-mode button:focus-visible, +.reactor-go:focus-visible, +.reactor-copy:focus-visible, +.rx-side-link:focus-visible { + outline: 2px solid #8f88ff; + outline-offset: 3px; +} + +.reactor-data { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin: 22px 0 0; + padding-top: 18px; + border-top: 1px solid rgba(244, 245, 241, 0.16); +} +.reactor-data > div:last-child { + text-align: right; +} +.reactor-data dt { + font-family: var(--mono); + font-size: 0.64rem; + letter-spacing: 1.6px; + color: rgba(244, 245, 241, 0.6); +} +.reactor-data dd { + margin: 4px 0 0; + font-family: var(--display); + font-size: 2.3rem; + line-height: 1; + color: #fff; +} +.reactor-per { + font-family: var(--mono); + font-size: 0.8rem; + letter-spacing: 1px; + color: rgba(244, 245, 241, 0.7); +} +.reactor-route { + margin: 6px 0 0; + font-family: var(--mono); + font-size: 0.66rem; + letter-spacing: 1.6px; + color: #a9a2ff; +} +.reactor.is-full .reactor-route { + color: #ffa53c; +} +.reactor-where { + margin: 2px 0 0; + font-family: var(--mono); + font-size: 0.6rem; + letter-spacing: 1.4px; + color: rgba(244, 245, 241, 0.5); +} +.reactor-bonus { + margin: 6px 0 0; + font-family: var(--mono); + font-size: 0.66rem; + letter-spacing: 1.6px; + color: #a9a2ff; + animation: rx-fade 0.3s ease-out; +} +.reactor.is-full .reactor-bonus { + color: #ffa53c; +} + +.reactor-cta { + margin-top: 20px; + display: grid; + gap: 10px; +} +.reactor-go, +.reactor-copy { + display: flex; + justify-content: center; + align-items: center; + text-align: center; + font-family: var(--mono); + text-transform: uppercase; + letter-spacing: 1.4px; + font-size: 0.78rem; + padding: 15px 16px; + border-radius: 4px; + text-decoration: none; + cursor: pointer; + transition: + transform 0.12s ease, + box-shadow 0.12s ease, + background 0.15s ease; +} +.reactor-go { + background: #3a2ff0; + color: #fff; + border: 2px solid #f4f5f1; + box-shadow: 4px 4px 0 #f4f5f1; +} +.reactor-go:active { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 #f4f5f1; +} +.reactor.is-full .reactor-go:not(.is-disabled) { + background: #eb7d00; + color: #15171b; +} +.reactor-go.is-disabled { + background: transparent; + color: rgba(244, 245, 241, 0.65); + border: 1.5px dashed rgba(244, 245, 241, 0.35); + box-shadow: none; + cursor: not-allowed; +} +.reactor-copy { + background: transparent; + color: #f4f5f1; + border: 1.5px solid rgba(244, 245, 241, 0.35); +} +.reactor-copy:hover { + border-color: #f4f5f1; +} +.reactor-fine { + margin: 4px 0 0; + font-size: 0.86rem; + line-height: 1.55; + color: rgba(244, 245, 241, 0.72); +} + +.rx-side { + padding: 26px 28px 30px; + display: flex; + flex-direction: column; + gap: 6px; +} +.rx-side-label { + font-family: var(--mono); + font-size: 0.68rem; + letter-spacing: 1.8px; + color: #a9a2ff; + margin: 0 0 6px; +} +.rx-goes { + list-style: none; + margin: 0 0 22px; + padding: 0; + display: grid; +} +.rx-goes li { + display: grid; + grid-template-columns: 34px 1fr; + gap: 10px; + padding: 12px 0; + border-top: 1px solid rgba(244, 245, 241, 0.14); +} +.rx-goes-num { + font-family: var(--mono); + font-size: 0.74rem; + color: #a9a2ff; + padding-top: 3px; +} +.rx-goes h3 { + font-size: 1.35rem; + color: #fff; +} +.rx-goes p { + margin: 3px 0 0; + font-size: 0.9rem; + line-height: 1.5; + color: rgba(244, 245, 241, 0.72); +} +.rx-tiers { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid rgba(244, 245, 241, 0.16); + border-radius: 4px; +} +.rx-tier { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 14px; + align-items: baseline; + padding: 11px 14px; + border-top: 1px solid rgba(244, 245, 241, 0.12); +} +.rx-tier:first-child { + border-top: none; +} +.rx-tier-name { + font-family: var(--display); + font-size: 1.35rem; + color: #fff; + text-transform: uppercase; +} +.rx-tier-price { + font-family: var(--mono); + font-size: 0.86rem; + color: #f4f5f1; +} +.rx-tier-coins { + font-family: var(--mono); + font-size: 0.68rem; + letter-spacing: 1.2px; + color: #a9a2ff; +} +.rx-tier-reactor .rx-tier-coins { + color: #ffa53c; +} +.rx-side-link { + align-self: flex-start; + margin: 10px 0 20px; + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 1.4px; + text-transform: uppercase; + color: #fff; +} +.rx-promise { + margin-top: auto; + padding: 16px 18px; + border: 1.5px solid #3a2ff0; + border-radius: 4px; + background: rgba(58, 47, 240, 0.14); +} +.rx-promise p:not(.rx-side-label) { + margin: 0; + font-size: 0.92rem; + line-height: 1.55; + color: rgba(244, 245, 241, 0.86); +} + +/* membership cards */ +.tier-intro { + max-width: 60ch; + margin-bottom: 28px; +} +.tier-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 24px; + align-items: stretch; +} +.tier-card { + display: flex; + flex-direction: column; + border: 2px solid var(--ink); + border-radius: 6px; + background: #fff; + box-shadow: 6px 6px 0 var(--ink); + overflow: hidden; +} +.tier-catalyst { + box-shadow: 6px 6px 0 var(--signal); +} +.tier-reactor { + box-shadow: 6px 6px 0 var(--high); +} +.tier-cover { + display: block; + width: 100%; + height: auto; + aspect-ratio: 920 / 400; + object-fit: cover; + border-bottom: 2px solid var(--ink); + background: var(--ink); +} +.tier-body { + display: flex; + flex-direction: column; + flex: 1; + padding: 20px 22px 24px; +} +.tier-level { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 0; + font-family: var(--mono); + font-size: 0.66rem; + letter-spacing: 1.4px; + color: var(--slate); +} +.tier-badge { + padding: 3px 8px; + border-radius: 20px; + background: var(--signal); + color: #fff; + font-size: 0.6rem; +} +.tier-card h3 { + font-size: 2.2rem; + margin-top: 8px; +} +.tier-price { + margin: 6px 0 0; + font-family: var(--display); + font-size: 2.8rem; + line-height: 1; +} +.tier-price span { + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 1.4px; + margin-left: 6px; + color: var(--slate); +} +.tier-coins { + margin: 8px 0 0; + font-family: var(--mono); + font-size: 0.7rem; + letter-spacing: 1.3px; + color: var(--signal); +} +.tier-reactor .tier-coins { + color: #a35400; +} +.tier-impact { + margin: 12px 0 0; + line-height: 1.55; + color: #2b2e34; +} +.tier-card .support-list { + flex: 1; + align-content: start; + margin-bottom: 20px; +} +.tier-card .btn-signal { + align-self: flex-start; +} +.tier-table-wrap { + margin-top: 36px; + overflow-x: auto; +} +.tier-table { + width: 100%; + border-collapse: collapse; + border: 1.5px solid var(--ink); + background: #fff; + font-size: 0.95rem; +} +.tier-table th, +.tier-table td { + text-align: left; + padding: 12px 14px; + border-top: 1px solid var(--line); + vertical-align: top; +} +.tier-table thead th { + font-family: var(--mono); + font-size: 0.66rem; + letter-spacing: 1.4px; + text-transform: uppercase; + font-weight: 500; + background: var(--surface); + border-top: none; +} +.tier-table tbody th { + font-family: var(--display); + font-weight: 400; + font-size: 1.25rem; + text-transform: uppercase; + white-space: nowrap; +} +.support-end { + margin-top: 28px; +} +.support-sign { + margin: 26px 0 0; + font-family: var(--mono); + letter-spacing: 2px; + font-size: 0.74rem; + color: var(--signal); +} + +@keyframes rx-spin { + to { + transform: rotate(360deg); + } +} +@keyframes rx-breathe { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } +} +@keyframes rx-core-in { + 0% { + transform: scale(0.86); + opacity: 0.7; + } + 60% { + transform: scale(1.06); + opacity: 1; + } + 100% { + transform: scale(1); + } +} +@keyframes rx-spark { + 0% { + opacity: 1; + transform: rotate(var(--a)) translateY(-45.5cqw); + } + 85% { + opacity: 1; + } + 100% { + opacity: 0; + transform: rotate(var(--a)) translateY(0); + } +} +@keyframes rx-fade { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: none; + } +} + +@media (max-width: 980px) { + .rx-grid { + grid-template-columns: minmax(0, 1fr); + } + .reactor { + border-right: none; + border-bottom: 1px solid rgba(244, 245, 241, 0.16); + } + .tier-grid { + grid-template-columns: minmax(0, 1fr); + max-width: 560px; + } +} +@media (max-width: 420px) { + .reactor, + .rx-side { + padding: 20px 16px 24px; + } + .reactor-data dd { + font-size: 1.9rem; + } + .reactor-presets button { + font-size: 0.72rem; + } +} +@media (prefers-reduced-motion: reduce) { + .rx-ring, + .rx-atom, + .rx-core, + .reactor-bonus { + animation: none; + } + .rx-spark { + display: none; + } + .rx-seg, + .reactor-presets button, + .reactor-mode button, + .reactor-go, + .reactor-copy { + transition: none; + } +} + +/* reactor in a row: dial beside the controls, support details below */ +.rx-grid { + grid-template-columns: minmax(0, 1fr); +} +.reactor { + border-right: none; + border-bottom: 1px solid rgba(244, 245, 241, 0.16); +} +.reactor-body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 40px; + align-items: center; +} +.reactor-dial { + margin: 10px auto 0; +} +.rx-goes { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 22px; +} +.rx-goes li { + display: flex; + flex-direction: column; + gap: 6px; +} +.rx-side-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 28px; + align-items: start; +} +.rx-promise { + margin-top: 0; +} +@media (max-width: 980px) { + .reactor-body { + grid-template-columns: minmax(0, 1fr); + gap: 8px; + } + .reactor-visual { + position: sticky; + top: 64px; + z-index: 2; + background: #15171b; + padding-bottom: 6px; + } + .reactor-dial { + width: min(100%, 250px); + } + .rx-goes { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .rx-side-row { + grid-template-columns: minmax(0, 1fr); + } +} +@media (max-width: 560px) { + .rx-goes { + grid-template-columns: minmax(0, 1fr); + } +} + +/* A phone on its side is too short to keep the dial pinned above the controls. */ +@media (max-width: 980px) and (max-height: 600px) { + .reactor-visual { + position: static; + } +} diff --git a/src/app/support-atomic-notes/page.tsx b/src/app/support-atomic-notes/page.tsx index eaa5b68..894f8af 100644 --- a/src/app/support-atomic-notes/page.tsx +++ b/src/app/support-atomic-notes/page.tsx @@ -1,17 +1,26 @@ import type { Metadata } from "next"; import { SiteNav } from "@/components/SiteNav"; import { SiteFooter } from "@/components/SiteFooter"; -import { KOFI_URL, PATREON_URL } from "@/lib/content"; +import { SupportReactor } from "@/components/SupportReactor"; +import { KOFI_URL, PATREON_JOIN_URL } from "@/lib/content"; +import { MEMBERSHIPS, formatUsd } from "@/lib/supportPricing"; import { pageMetadata } from "@/lib/seo"; export const metadata: Metadata = pageMetadata({ - title: "Support Atomic Notes, get Atomic Coins early", + title: "Support Atomic Notes: power the build, get Atomic Coins", description: - "Support Atomic Notes on Patreon or Ko-fi at the amount you choose. Send your Atomic Notes account email, and the developer sends you Atomic Coins as an early-supporter reward.", + "Help keep Atomic Notes independent. Support it once on Ko-fi or monthly on Patreon, and get Atomic Coins as a thank-you. Your notes stay yours either way.", path: "/support-atomic-notes", - socialTitle: "Support Atomic Notes, get Atomic Coins early", + socialTitle: "Support Atomic Notes: power the build", }); +const GOES_TO: { t: string; d: string }[] = [ + { t: "Sync infrastructure", d: "The server and database that keep your devices in step." }, + { t: "Testing and bug fixes", d: "Real phones, real networks, and the bugs they find." }, + { t: "Security and reliability", d: "The vault, sync safety, and the boring work that keeps them sound." }, + { t: "Future releases", d: "New features, more platforms, and wider distribution." }, +]; + const STEPS: { t: string; d: React.ReactNode }[] = [ { t: "Find your email", @@ -23,11 +32,11 @@ const STEPS: { t: string; d: React.ReactNode }[] = [ ), }, { - t: "Pick a platform", + t: "Support once or monthly", d: ( <> - Support the DevBehindYou page on Patreon or Ko-fi, at the amount you choose. Both reach the same - developer and earn the same reward. + One-time support goes through Ko-fi, where you enter the amount. Monthly support is a Patreon{" "} + membership. Both reach the same developer. ), }, @@ -35,8 +44,8 @@ const STEPS: { t: string; d: React.ReactNode }[] = [ t: "Send your email", d: ( <> - On Patreon, send the developer a message. On Ko-fi, write it in the message box when you pay and mark the - message private. Check it letter by letter: the coins go to exactly that account. + On Ko-fi, paste the message from the reactor, add your email, and mark it private. On Patreon, send the + developer a message. Never send a password, recovery phrase, or sign-in code. ), }, @@ -44,35 +53,45 @@ const STEPS: { t: string; d: React.ReactNode }[] = [ t: "Get your coins", d: ( <> - The developer adds your early-supporter Atomic Coins to that account by hand. They show up in Atomic Energy - after the next refresh. + The developer checks the payment and adds the coins to that account by hand, so it can take a little time. + They show up in Atomic Energy after the next refresh. ), }, ]; -const PLATFORMS: { name: string; tag: string; url: string; label: string; points: string[] }[] = [ +const FAQ: { q: string; a: React.ReactNode }[] = [ + { + q: "Do I need to support to use Atomic Notes?", + a: "No. Writing, editing, and keeping notes on your phone never depends on a payment or a membership.", + }, + { + q: "What do Atomic Coins do?", + a: "They're in-app credits. You can turn them into Atomic Energy for cloud sync, or use them to make room for more notes.", + }, + { + q: "Are coins added instantly?", + a: "Not yet. The developer adds them by hand after checking the payment and the account email, so they can take a little time. These steps will be updated if delivery becomes automatic.", + }, + { + q: "Which amounts go to Patreon and which to Ko-fi?", + a: "$2, $4, and $10 are the monthly memberships on Patreon: Atomic Spark, Atomic Catalyst, and Atomic Reactor. Any other amount from $1 to $10 is a one-time payment on Ko-fi, where you enter the amount yourself.", + }, { - name: "Patreon", - tag: "MEMBERSHIP", - url: PATREON_URL, - label: "Support on Patreon", - points: [ - "Usually a monthly membership", - "Send your account email in a Patreon message", - "Cancel on Patreon at any time", - ], + q: "What if I pay a different amount on Ko-fi?", + a: "Coins follow the amount you actually paid, using the reactor's table for $1 to $10. For more than $10, message the developer before paying.", }, { - name: "Ko-fi", - tag: "ONE-TIME OR MONTHLY", - url: KOFI_URL, - label: "Support on Ko-fi", - points: [ - "Good for a single payment", - "Put your account email in the message and mark it private", - "Monthly support is also available on Ko-fi", - ], + q: "Can I support without getting coins?", + a: "Yes. Choose Support only in the reactor, or just leave a tip on Ko-fi without an email.", + }, + { + q: "What if I sent the wrong account email?", + a: "Message the developer on the platform you used, with your payment reference. Never share your password, recovery phrase, or a one-time code.", + }, + { + q: "Can I cancel a Patreon membership?", + a: "Yes, at any time on Patreon. Monthly coins are tied to each month that's actually paid.", }, ]; @@ -80,54 +99,149 @@ export default function SupportPage() { return ( <> -
+
-

SUPPORT ATOMIC NOTES

+

ATOMIC NOTES / INDEPENDENT DEVELOPMENT

- Support the build. Get coins early. + Help keep Atomic Notes independent.

- Atomic Notes is built by one developer, with no ads and no trackers. Coins aren't sold in the app - yet. Support the project on Patreon or Ko-fi, and the developer sends Atomic Coins to your account as an - early-supporter reward. + Atomic Notes is a source-available, local-first notes app built by one developer, with no ads, no + trackers, and no AI. Your support funds sync infrastructure, security work, real-device testing, and + future releases. As a thank-you, supporters get Atomic Coins.

+

Your notes stay yours, whether or not you support.

-
+
-

TWO WAYS TO SUPPORT

+

ATOMIC NOTES / SUPPORT SYSTEM 01

- Pick the one that suits you. + Power the build.

-
- {PLATFORMS.map((p) => ( -
-

{p.tag}

-

{p.name}

-
    - {p.points.map((pt) => ( -
  • {pt}
  • - ))} -
- - {p.label} - +

+ Pick an amount and see your Atomic Coins. $2, $4, and $10 are the monthly memberships on Patreon. Any + other amount is a one-time payment on Ko-fi. +

+
+ + +
+
+
+ +
+
+

MONTHLY ON PATREON

+

+ Choose your orbit. +

+

+ Three memberships, each with Atomic Coins for every month that's paid. Cancel on Patreon at any time. +

+
+ {MEMBERSHIPS.map((m) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+

+ TIER {m.level} · {m.tagline.toUpperCase()} + {m.badge ? {m.badge.toUpperCase()} : null} +

+

{m.name}

+

+ {formatUsd(m.cents).replace(".00", "")} + / MONTH +

+

{m.coins} ATOMIC COINS EACH PAID MONTH

+

{m.impact}

+
    + {m.benefits.map((b) => ( +
  • {b}
  • + ))} +
+ + {m.cta} + +
+
))}
+ +
+ + + + + + + + + + + + {MEMBERSHIPS.map((m) => ( + + + + + + + ))} + +
Patreon memberships compared
MembershipPriceCoins each paid monthAlso included
{m.name}{formatUsd(m.cents)} a month{m.coins}{m.benefits.slice(1).join(", ")}
+
@@ -135,7 +249,7 @@ export default function SupportPage() {

FOUR STEPS

- How the reward reaches you. + How the coins reach you.

    {STEPS.map((s, i) => ( @@ -152,10 +266,9 @@ export default function SupportPage() {

    IMPORTANT

    Use the exact account email

    - The reward is sent to the email you write, not to your Patreon or Ko-fi name. A wrong or misspelled - email means the coins can't reach you. If you signed in with more than one Google account, use the - one that holds your notes. On Ko-fi, mark the message private so your email isn't shown on the - public page. + Coins go to the email you write, not to your Patreon or Ko-fi name. A wrong or misspelled email means + the coins can't reach you. If you signed in with more than one Google account, use the one that + holds your notes. On Ko-fi, mark the message private so your email isn't shown on the public page.

@@ -165,7 +278,7 @@ export default function SupportPage() { Patreon or Ko-fi handles the payment. Atomic Notes never sees your card, PayPal, or bank details.
  • Each platform charges its own fees, under its own terms.
  • -
  • The developer sends rewards by hand, so they can take a little time to arrive.
  • +
  • Coins are added by hand after the payment is checked, so they can take a little time.
  • Writing notes stays free. Coins only buy sync energy and room for more notes.
  • @@ -197,14 +310,40 @@ export default function SupportPage() {
    30 more coins, room for 100 notes
    -
    - - Support on Patreon +
    +
    + +
    +
    +

    FAQ

    +

    + Straight answers. +

    +
    + {FAQ.map((f, i) => ( +
    + +

    {f.q}

    + +
    +

    {f.a}

    +
    + ))} +
    +
    +

    EVERY ELECTRON HELPS.

    diff --git a/src/components/SupportReactor.tsx b/src/components/SupportReactor.tsx new file mode 100644 index 0000000..8459c76 --- /dev/null +++ b/src/components/SupportReactor.tsx @@ -0,0 +1,300 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { KOFI_URL, PATREON_JOIN_URL } from "@/lib/content"; +import { + DEFAULT_CENTS, + MAX_CENTS, + MIN_CENTS, + PRESET_CENTS, + REWARD_STEPS, + STEP_CENTS, + formatUsd, + quote, + supportMessage, +} from "@/lib/supportPricing"; + +const CX = 200; +const CY = 200; +const SEG_R = 182; +const SEGMENTS = REWARD_STEPS.length; + +/** One arc of the outer dial, for dollar step [i]. */ +function segmentPath(i: number): string { + const span = 360 / SEGMENTS; + const gap = 5; + const a0 = ((i * span + gap / 2 - 90) * Math.PI) / 180; + const a1 = (((i + 1) * span - gap / 2 - 90) * Math.PI) / 180; + const x0 = CX + SEG_R * Math.cos(a0); + const y0 = CY + SEG_R * Math.sin(a0); + const x1 = CX + SEG_R * Math.cos(a1); + const y1 = CY + SEG_R * Math.sin(a1); + return `M ${x0.toFixed(2)} ${y0.toFixed(2)} A ${SEG_R} ${SEG_R} 0 0 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`; +} + +function usePrefersReducedMotion(): boolean { + const [reduced, setReduced] = useState(false); + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + const update = () => setReduced(mq.matches); + update(); + mq.addEventListener("change", update); + return () => mq.removeEventListener("change", update); + }, []); + return reduced; +} + +/** Counts from the shown value to [target] in about 300 ms, or jumps when motion is reduced. */ +function useRollingNumber(target: number, reduced: boolean): number { + const [shown, setShown] = useState(target); + const from = useRef(target); + useEffect(() => { + if (reduced) { + from.current = target; + setShown(target); + return; + } + const start = performance.now(); + const begin = from.current; + let raf = 0; + const tick = (now: number) => { + const p = Math.min(1, (now - start) / 300); + const eased = 1 - Math.pow(1 - p, 3); + const v = Math.round(begin + (target - begin) * eased); + from.current = v; + setShown(v); + if (p < 1) raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + // requestAnimationFrame pauses in background tabs: make sure the final number still lands. + const settle = window.setTimeout(() => { + from.current = target; + setShown(target); + }, 360); + return () => { + cancelAnimationFrame(raf); + window.clearTimeout(settle); + }; + }, [target, reduced]); + return shown; +} + +export function SupportReactor() { + const [cents, setCents] = useState(DEFAULT_CENTS); + const [supportOnly, setSupportOnly] = useState(false); + const [pulse, setPulse] = useState(0); + const [copied, setCopied] = useState(false); + const reduced = usePrefersReducedMotion(); + + const q = quote(cents); + const steps = cents / STEP_CENTS; + const full = cents === MAX_CENTS; + const monthly = q.channel === "patreon" && !supportOnly; + const coinsShown = useRollingNumber(q.coins, reduced); + const usd = formatUsd(cents); + const where = monthly ? "Patreon" : "Ko-fi"; + + function choose(next: number) { + if (next === cents) return; + setCents(next); + setPulse((p) => p + 1); + setCopied(false); + } + + async function copyMessage() { + try { + await navigator.clipboard.writeText(supportMessage(q)); + setCopied(true); + window.setTimeout(() => setCopied(false), 2400); + } catch { + setCopied(false); + } + } + + const sparkAngle = ((steps - 0.5) * 360) / SEGMENTS; + + return ( +
    +
    +

    ATOMIC COIN REACTOR

    +

    {full ? "FULL POWER" : `STEP ${String(steps).padStart(2, "0")} / 10`}

    +
    + +
    +
    +
    + + + + + + + + + + + + + + + {REWARD_STEPS.map((s, i) => ( + + ))} + + + + + + + {[0, 60, 120].map((deg) => ( + + + + + ))} + + + + + +
    +
    + +
    +

    + {supportOnly + ? `${usd} one-time support on Ko-fi, no coins` + : monthly + ? `${usd} a month on Patreon, ${q.membership?.name}: ${q.coins} Atomic Coins each paid month` + : `${usd} one-time on Ko-fi: ${q.coins} Atomic Coins`} +

    + +
    + + choose(Number(e.target.value) * 100)} + aria-valuetext={`${usd}, ${q.coins} Atomic Coins`} + style={{ "--fill": `${((steps - 1) / 9) * 100}%` } as React.CSSProperties} + /> + +
    + +
    + {PRESET_CENTS.map((p) => { + const m = quote(p).membership; + return ( + + ); + })} +
    + +
    + + +
    + +
    +
    +
    {monthly ? "MONTHLY ON PATREON" : "ONE-TIME ON KO-FI"}
    +
    + {usd} + {monthly ? / MO : null} +
    + {monthly ?

    {q.membership?.name.toUpperCase()}

    : null} +
    +
    +
    {monthly ? "EACH PAID MONTH" : "YOU RECEIVE"}
    +
    {supportOnly ? "OUR THANKS" : `${q.coins} COINS`}
    + {!supportOnly && q.bonusCoins > 0 ? ( +

    + +{q.bonusCoins} BONUS COINS +

    + ) : null} +
    +
    + +
    + {supportOnly ? ( + <> + + Support {usd} on Ko-fi + +

    + No coins and no account email needed. Enter {usd} on Ko-fi, where the payment happens. +

    + + ) : monthly ? ( + <> + + Join {q.membership?.name} on Patreon + + +

    + A monthly membership: {usd} a month and {q.coins} Atomic Coins each paid month. Cancel any time on + Patreon. After joining, send the message with your Atomic Notes account email. Coins are added by hand + after the developer checks the payment. +

    + + ) : ( + <> + + Support {usd} on Ko-fi & get {q.coins} coins + + +

    + A one-time payment. On Ko-fi, enter {usd}, paste the message, add your Atomic Notes account email, and + mark it private. Coins are added by hand after the developer checks the payment. +

    + + )} +

    PAYMENT ON {where.toUpperCase()} · THIS PAGE ONLY PREVIEWS THE REWARD

    +
    +
    +
    +
    + ); +} diff --git a/src/lib/content.ts b/src/lib/content.ts index 29b4490..208a99f 100644 --- a/src/lib/content.ts +++ b/src/lib/content.ts @@ -56,6 +56,8 @@ export const REPO_URL = "https://github.com/DevBehindYou/Atomic-Notes-App-V0.2"; /** Where supporters go. Early supporters get Atomic Coins by hand (see /support-atomic-notes). */ export const PATREON_URL = "https://www.patreon.com/cw/DevBehindYou"; +/** Patreon's membership page, where a supporter picks a tier. */ +export const PATREON_JOIN_URL = "https://www.patreon.com/16654968/join"; export const KOFI_URL = "https://ko-fi.com/devbehindyou"; /** The answer-first definition, used in the page and in structured data. */ diff --git a/src/lib/supportPricing.ts b/src/lib/supportPricing.ts new file mode 100644 index 0000000..3c4980f --- /dev/null +++ b/src/lib/supportPricing.ts @@ -0,0 +1,139 @@ +/** + * Support rewards: Atomic Coins for each whole-dollar amount from $1 to $10, and where to pay. + * + * This is a versioned table, not a formula. Amounts are in integer US cents. + * - $2, $4 and $10 are the Patreon memberships (Spark, Catalyst, Reactor): the same coins each paid month. + * - Every other amount is a one-time payment on Ko-fi, where the supporter enters the amount. + * + * The page only previews rewards. Coins are granted by hand on the Server, after the payment is + * checked, against the amount actually paid. Change a reward here and bump the version. + */ + +export const SUPPORT_PRICING_VERSION = "2026-10-11.v2"; + +/** Coins per dollar before any bonus. */ +export const BASE_COINS_PER_DOLLAR = 10; + +export type MembershipId = "spark" | "catalyst" | "reactor"; +export type Channel = "patreon" | "kofi"; + +export type RewardStep = { + cents: number; + coins: number; + /** Set when this amount is a Patreon membership; otherwise it's a one-time Ko-fi payment. */ + membership?: MembershipId; +}; + +export const REWARD_STEPS: readonly RewardStep[] = [ + { cents: 100, coins: 10 }, + { cents: 200, coins: 20, membership: "spark" }, + { cents: 300, coins: 30 }, + { cents: 400, coins: 50, membership: "catalyst" }, + { cents: 500, coins: 65 }, + { cents: 600, coins: 80 }, + { cents: 700, coins: 95 }, + { cents: 800, coins: 110 }, + { cents: 900, coins: 130 }, + { cents: 1000, coins: 150, membership: "reactor" }, +]; + +export const MIN_CENTS = REWARD_STEPS[0].cents; +export const MAX_CENTS = REWARD_STEPS[REWARD_STEPS.length - 1].cents; +export const STEP_CENTS = 100; +export const DEFAULT_CENTS = 400; +export const PRESET_CENTS: readonly number[] = [200, 400, 1000]; + +export type Quote = { + cents: number; + coins: number; + bonusCoins: number; + channel: Channel; + membership: Membership | null; + version: string; +}; + +export type Membership = { + id: MembershipId; + level: string; + name: string; + tagline: string; + cents: number; + coins: number; + impact: string; + benefits: string[]; + cta: string; + cover: string; + badge?: string; +}; + +export const MEMBERSHIPS: readonly Membership[] = [ + { + id: "spark", + level: "01", + name: "Atomic Spark", + tagline: "Ignite the build", + cents: 200, + coins: 20, + impact: "A small, steady push that keeps independent development moving.", + benefits: ["20 Atomic Coins each paid month", "Supporter development updates"], + cta: "Ignite the spark", + cover: "/support/atomic-spark-cover.webp", + }, + { + id: "catalyst", + level: "02", + name: "Atomic Catalyst", + tagline: "Keep the orbit moving", + cents: 400, + coins: 50, + impact: "Helps cover sync infrastructure, debugging, and real-device testing.", + benefits: ["50 Atomic Coins each paid month", "Supporter development updates"], + cta: "Become a catalyst", + cover: "/support/atomic-catalyst-cover.webp", + badge: "Good balance", + }, + { + id: "reactor", + level: "03", + name: "Atomic Reactor", + tagline: "Power the build", + cents: 1000, + coins: 150, + impact: "Backs the deep work: security, sync reliability, testing, and future releases.", + benefits: [ + "150 Atomic Coins each paid month", + "Supporter development updates", + "A personal thank-you from the developer when you join", + "Your feature ideas read when planning what comes next", + ], + cta: "Power the reactor", + cover: "/support/atomic-reactor-cover.webp", + }, +]; + +/** The reward and payment route for an exact amount. Throws for any amount not in the table. */ +export function quote(cents: number): Quote { + const step = REWARD_STEPS.find((s) => s.cents === cents); + if (!Number.isSafeInteger(cents) || !step) throw new Error(`Unsupported contribution amount: ${cents}`); + const membership = step.membership ? (MEMBERSHIPS.find((m) => m.id === step.membership) ?? null) : null; + return { + cents, + coins: step.coins, + bonusCoins: Math.max(0, step.coins - (step.cents / 100) * BASE_COINS_PER_DOLLAR), + channel: membership ? "patreon" : "kofi", + membership, + version: SUPPORT_PRICING_VERSION, + }; +} + +export function formatUsd(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +/** The text a supporter sends with their payment, so the reward can be matched by hand. */ +export function supportMessage(q: Quote): string { + const what = q.membership + ? `${q.membership.name} membership (${formatUsd(q.cents)} a month, ${q.coins} Atomic Coins each paid month)` + : `${formatUsd(q.cents)} one-time support for ${q.coins} Atomic Coins`; + return `Atomic Notes: ${what}, rewards ${q.version}. My Atomic Notes account email: `; +} diff --git a/tests/support-pricing.test.mjs b/tests/support-pricing.test.mjs new file mode 100644 index 0000000..d4db240 --- /dev/null +++ b/tests/support-pricing.test.mjs @@ -0,0 +1,75 @@ +// Run with: npm run test:pricing (Node 22+, using --experimental-strip-types). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + DEFAULT_CENTS, + MEMBERSHIPS, + PRESET_CENTS, + REWARD_STEPS, + formatUsd, + quote, + supportMessage, +} from "../src/lib/supportPricing.ts"; + +// The table from 03_COIN_PRICING_IMPLEMENTATION.md, confirmed by the owner on 2026-10-11. +const TABLE = { 100: 10, 200: 20, 300: 30, 400: 50, 500: 65, 600: 80, 700: 95, 800: 110, 900: 130, 1000: 150 }; + +test("every whole-dollar amount from $1 to $10 gives the exact table reward", () => { + for (const [cents, coins] of Object.entries(TABLE)) assert.equal(quote(Number(cents)).coins, coins, `${cents} cents`); +}); + +test("bonus is measured against 10 coins per dollar", () => { + assert.deepEqual( + Object.keys(TABLE).map((c) => quote(Number(c)).bonusCoins), + [0, 0, 0, 10, 15, 20, 25, 30, 40, 50], + ); +}); + +test("$2, $4 and $10 route to the Patreon memberships", () => { + assert.deepEqual( + [200, 400, 1000].map((c) => [quote(c).channel, quote(c).membership?.name]), + [ + ["patreon", "Atomic Spark"], + ["patreon", "Atomic Catalyst"], + ["patreon", "Atomic Reactor"], + ], + ); +}); + +test("every other amount routes to a one-time Ko-fi payment", () => { + for (const cents of [100, 300, 500, 600, 700, 800, 900]) { + const q = quote(cents); + assert.equal(q.channel, "kofi"); + assert.equal(q.membership, null); + } +}); + +test("membership rewards equal the table rewards for the same price", () => { + for (const m of MEMBERSHIPS) assert.equal(m.coins, TABLE[m.cents], m.name); +}); + +test("amounts outside the table are rejected, never interpolated", () => { + for (const cents of [0, 50, 150, 450, 1100, -100, 400.5, Number.NaN]) { + assert.throws(() => quote(cents), /Unsupported/); + } +}); + +test("the table covers every dollar from $1 to $10, in order", () => { + assert.deepEqual(REWARD_STEPS.map((s) => s.cents), Object.keys(TABLE).map(Number)); +}); + +test("presets are the three memberships, and the default is Catalyst", () => { + assert.deepEqual(PRESET_CENTS, [200, 400, 1000]); + assert.equal(quote(DEFAULT_CENTS).membership?.id, "catalyst"); +}); + +test("the copied message names the route, amount, coins, and version", () => { + assert.match(supportMessage(quote(700)), /\$7\.00 one-time support for 95 Atomic Coins, rewards 2026-10-11\.v2/); + assert.match(supportMessage(quote(400)), /Atomic Catalyst membership \(\$4\.00 a month, 50 Atomic Coins each paid month\)/); + assert.match(supportMessage(quote(100)), /account email: $/); + assert.equal(formatUsd(1000), "$10.00"); +}); + +test("copy never calls Atomic Notes open source", () => { + assert.doesNotMatch(JSON.stringify(MEMBERSHIPS), /open[ -]source/i); +});