Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions e2e/support.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
25 changes: 25 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 8 additions & 1 deletion src/components/shell/ShellIsland.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -30,11 +30,13 @@ const menuItem =
const S: Record<Lang, {
searchAria: string; searchPre: string; searchPost: string; askAgent: string;
about: string; contribute: string; contribMenu: string; more: string; settings: string;
support: string;
modalTitle: string; modalBody: ReactNode;
}> = {
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: (
<>
Expand All @@ -46,6 +48,7 @@ const S: Record<Lang, {
id: {
searchAria: 'Cari tool', searchPre: 'Tekan', searchPost: 'untuk mencari', askAgent: 'Tanya agen',
about: 'Tentang', contribute: 'Kontribusi di GitHub', contribMenu: 'Kontribusi', more: 'Lainnya', settings: 'Pengaturan',
support: 'Belikan saya kopi',
modalTitle: 'Kontribusi',
modalBody: (
<>
Expand Down Expand Up @@ -137,6 +140,9 @@ export function ShellIsland() {
<a href="/about" aria-label={s.about} title={s.about} className={iconBtn}>
<Info className="h-4 w-4" />
</a>
<a href="/support" aria-label={s.support} title={s.support} className={iconBtn}>
<Coffee className="h-4 w-4" />
</a>
<button onClick={() => setModal('github')} aria-label={s.contribute} title={s.contribute} className={iconBtn}>
<Github className="h-4 w-4" />
</button>
Expand All @@ -162,6 +168,7 @@ export function ShellIsland() {
<a href="/settings" role="menuitem" className={menuItem}><Settings className="h-4 w-4" /> {s.settings}</a>
)}
<a href="/about" role="menuitem" className={menuItem}><Info className="h-4 w-4" /> {s.about}</a>
<a href="/support" role="menuitem" className={menuItem}><Coffee className="h-4 w-4" /> {s.support}</a>
<button role="menuitem" onClick={() => { setMenuOpen(false); setModal('github'); }} className={menuItem}>
<Github className="h-4 w-4" /> {s.contribMenu}
</button>
Expand Down
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions src/islands/shell/SupportButton.tsx
Original file line number Diff line number Diff line change
@@ -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<Lang, { cta: string; opening: string }> = {
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 (
<button
type="button"
onClick={() => void open()}
disabled={busy}
className="inline-flex items-center gap-2 border-2 border-border bg-accent px-4 py-2 font-bold uppercase tracking-wide text-accent-foreground shadow-brutal press-brutal disabled:opacity-60"
>
<Coffee className="h-4 w-4" />
{busy ? t.opening : t.cta}
</button>
);
}
62 changes: 62 additions & 0 deletions src/pages/[...locale]/support.astro
Original file line number Diff line number Diff line change
@@ -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'];
---

<Base title={A.title} description={A.metaDesc} lang={lang} localized>
<main class="page-container py-8">
<nav aria-label="Breadcrumb" class="mb-4">
<ol class="flex items-center gap-1 text-sm font-bold uppercase tracking-wide text-muted-foreground">
<li>
<a href={homePath} class="border-2 border-border bg-muted px-2 py-1 text-foreground shadow-brutal-sm press-brutal">{A.home}</a>
</li>
<li aria-hidden="true"><ChevronRight className="h-3.5 w-3.5" /></li>
<li aria-current="page" class="text-foreground">{A.crumb}</li>
</ol>
</nav>

<h1 class="mb-6 text-3xl font-bold uppercase tracking-tight">{A.h1}</h1>

<div class="max-w-2xl space-y-6 text-base leading-relaxed">
<section class="space-y-4 border-2 border-border bg-accent/10 p-5 shadow-brutal-sm">
<h2 class="flex items-center gap-2 text-xl font-bold uppercase tracking-tight">
<Heart className="h-5 w-5" /> {A.leadH}
</h2>
<p>{A.leadP}</p>
{POLAR_CHECKOUT_URL && <SupportButton client:idle lang={lang} />}
<p class="text-sm text-muted-foreground">{A.privP}</p>
</section>

<p class="font-bold">{A.thanks}</p>
</div>
</main>
</Base>
Loading