diff --git a/e2e/support.spec.ts b/e2e/support.spec.ts new file mode 100644 index 0000000..7ba14a0 --- /dev/null +++ b/e2e/support.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from '@playwright/test'; + +test('support page renders with the Buy me a coffee button', async ({ page }) => { + const res = await page.goto('/support'); + expect(res?.status()).toBe(200); + await expect(page.getByRole('heading', { name: /Support GoodWebTools/i })).toBeVisible(); + // The button is a client:idle island — wait for hydration. + await expect(page.getByRole('button', { name: 'Buy me a coffee' })).toBeVisible({ timeout: 30_000 }); +}); + +test('header links to the support page', async ({ page }) => { + await page.goto('/'); + await page.waitForLoadState('networkidle').catch(() => {}); + const link = page.locator('a[href="/support"]').first(); + await expect(link).toHaveCount(1); +}); diff --git a/package-lock.json b/package-lock.json index 10ee02c..e73e9c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@mediapipe/tasks-vision": "^0.10.35", "@mlc-ai/web-llm": "^0.2.84", "@nanostores/react": "^0.7.3", + "@polar-sh/checkout": "^0.4.1", "@sqlite.org/sqlite-wasm": "^3.50.1-build1", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", @@ -4866,6 +4867,20 @@ "node": ">=20" } }, + "node_modules/@polar-sh/checkout": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@polar-sh/checkout/-/checkout-0.4.1.tgz", + "integrity": "sha512-/pxHOea10k9BZPz1N7xl8fF3Q+/G6l4l6tuAolfAzIB3J5+xKhcA4/+BlnIg4dHRYtF2KyDUNvhBbufgEQ8AyA==", + "license": "Apache-2.0", + "dependencies": { + "date-fns": "^4.1.0" + }, + "peerDependencies": { + "@stripe/react-stripe-js": "^3.6.0 || ^4.0.2 || ^5.6.1", + "@stripe/stripe-js": "^7.1.0 || ^8.11.0", + "react": "^18 || ^19" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -10723,6 +10738,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", diff --git a/package.json b/package.json index 4ad807a..5efb43e 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@mediapipe/tasks-vision": "^0.10.35", "@mlc-ai/web-llm": "^0.2.84", "@nanostores/react": "^0.7.3", + "@polar-sh/checkout": "^0.4.1", "@sqlite.org/sqlite-wasm": "^3.50.1-build1", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", diff --git a/src/components/shell/ShellIsland.tsx b/src/components/shell/ShellIsland.tsx index 3c709aa..3554e36 100644 --- a/src/components/shell/ShellIsland.tsx +++ b/src/components/shell/ShellIsland.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; -import { Search, Github, Info, ExternalLink, Settings, MoreVertical, Sparkles } from 'lucide-react'; +import { Search, Github, Info, ExternalLink, Settings, MoreVertical, Sparkles, Coffee } from 'lucide-react'; import { ThemeToggle } from './ThemeToggle'; import { LangSwitcher } from './LangSwitcher'; import { CommandPalette } from './CommandPalette'; @@ -30,11 +30,13 @@ const menuItem = const S: Record = { en: { searchAria: 'Search tools', searchPre: 'Press', searchPost: 'to search', askAgent: 'Ask agent', about: 'About', contribute: 'Contribute on GitHub', contribMenu: 'Contribute', more: 'More', settings: 'Settings', + support: 'Buy me a coffee', modalTitle: 'Contribute', modalBody: ( <> @@ -46,6 +48,7 @@ const S: Record @@ -137,6 +140,9 @@ export function ShellIsland() { + + + @@ -162,6 +168,7 @@ export function ShellIsland() { {s.settings} )} {s.about} + {s.support} diff --git a/src/config.ts b/src/config.ts index 727e587..847eff7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,13 @@ export const SITE_URL = 'https://goodwebtools.com'; export const SITE_NAME = 'GoodWebTools'; export const SITE_TAGLINE = 'Privacy-first client-side utilities that run entirely in your browser'; +/** + * Polar (polar.sh) hosted checkout link for optional "Buy me a coffee" support. + * A checkout link is a public payment URL (not a secret). When empty, the + * support entry points are hidden. Payment happens in Polar's embedded overlay. + */ +export const POLAR_CHECKOUT_URL = 'https://buy.polar.sh/polar_cl_HMptINBlWf8fgwVIg1lGAihltf5FDKUN2yHIE3RNbEH'; + /** * Google Analytics 4 measurement ID. A GA4 ID is not secret — it's visible in * every visitor's page source — so it lives in the repo for reliability (a diff --git a/src/islands/shell/SupportButton.tsx b/src/islands/shell/SupportButton.tsx new file mode 100644 index 0000000..296804c --- /dev/null +++ b/src/islands/shell/SupportButton.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import { Coffee } from 'lucide-react'; +import { POLAR_CHECKOUT_URL } from '@/config'; +import type { Lang } from '@/i18n/config'; + +const TR: Record = { + en: { cta: 'Buy me a coffee', opening: 'Opening…' }, + id: { cta: 'Belikan saya kopi', opening: 'Membuka…' }, +}; + +/** Reads the current theme so the Polar overlay matches the site. */ +function currentTheme(): 'light' | 'dark' { + if (typeof document === 'undefined') return 'light'; + const root = document.documentElement; + if (root.classList.contains('dark') || root.dataset.theme === 'dark') return 'dark'; + if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches) return 'dark'; + return 'light'; +} + +export default function SupportButton({ lang = 'en' }: { lang?: Lang }) { + const t = TR[lang] ?? TR.en; + const [busy, setBusy] = useState(false); + + if (!POLAR_CHECKOUT_URL) return null; + + const open = async () => { + setBusy(true); + try { + // Load Polar's embed on demand so it never weighs on other pages. + const { PolarEmbedCheckout } = await import('@polar-sh/checkout/embed'); + await PolarEmbedCheckout.create(POLAR_CHECKOUT_URL, { theme: currentTheme() }); + } catch { + // Fall back to the hosted checkout page if the overlay can't load. + window.open(POLAR_CHECKOUT_URL, '_blank', 'noopener,noreferrer'); + } finally { + setBusy(false); + } + }; + + return ( + + ); +} diff --git a/src/pages/[...locale]/support.astro b/src/pages/[...locale]/support.astro new file mode 100644 index 0000000..44fd984 --- /dev/null +++ b/src/pages/[...locale]/support.astro @@ -0,0 +1,62 @@ +--- +import { ChevronRight, Heart } from 'lucide-react'; +import Base from '@/layouts/Base.astro'; +import SupportButton from '@/islands/shell/SupportButton'; +import { POLAR_CHECKOUT_URL } from '@/config'; +import { LOCALES, localeParam, localizePath, type Lang } from '@/i18n/config'; + +export function getStaticPaths() { + return LOCALES.map(lang => ({ params: { locale: localeParam(lang) }, props: { lang } })); +} + +const { lang } = Astro.props as { lang: Lang }; +const homePath = localizePath('/', lang); + +const A = { + en: { + title: 'Support', metaDesc: 'GoodWebTools is free and private, with no accounts. If a tool saved you time, you can buy me a coffee — entirely optional.', + crumb: 'Support', home: 'Home', h1: 'Support GoodWebTools', + leadH: 'Buy me a coffee', + leadP: "GoodWebTools is free, runs entirely in your browser, and has no accounts or paywalls. If a tool saved you time and you'd like to chip in toward the running costs, you can buy me a coffee. It's completely optional — the tools stay free either way.", + privP: 'Payment is handled securely by Polar; your files and data never touch it, and nothing here is tracked.', + thanks: 'Thank you for using GoodWebTools. ❤️', + }, + id: { + title: 'Dukungan', metaDesc: 'GoodWebTools gratis dan privat, tanpa akun. Jika sebuah tool menghemat waktu Anda, Anda bisa membelikan saya kopi — sepenuhnya opsional.', + crumb: 'Dukungan', home: 'Beranda', h1: 'Dukung GoodWebTools', + leadH: 'Belikan saya kopi', + leadP: 'GoodWebTools gratis, berjalan sepenuhnya di browser Anda, tanpa akun atau paywall. Jika sebuah tool menghemat waktu Anda dan Anda ingin ikut membantu biaya operasional, Anda bisa membelikan saya kopi. Ini sepenuhnya opsional — tool-nya tetap gratis.', + privP: 'Pembayaran ditangani dengan aman oleh Polar; berkas dan data Anda tidak menyentuhnya, dan tidak ada yang dilacak di sini.', + thanks: 'Terima kasih telah memakai GoodWebTools. ❤️', + }, +}[lang === 'id' ? 'id' : 'en']; +--- + + +
+ + +

{A.h1}

+ +
+
+

+ {A.leadH} +

+

{A.leadP}

+ {POLAR_CHECKOUT_URL && } +

{A.privP}

+
+ +

{A.thanks}

+
+
+