diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7a50da2..f8846c6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -82,6 +82,7 @@ jobs: - name: Verify server JavaScript syntax run: | node --check server/src/index.js + node --check server/src/monetization.js node --check server/src/writeups.js - name: Verify build output diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..7237c44 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,41 @@ +name: Validate portfolio + +on: + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + package-manager-cache: false + + - name: Install and build client + working-directory: client + run: | + npm install + npm run build + + - name: Install and build admin + working-directory: admin + run: | + npm install + npm run build + + - name: Check server JavaScript + run: | + node --check server/src/index.js + node --check server/src/monetization.js + node --check server/src/writeups.js + node --check server/src/github-app.js diff --git a/admin/src/BuyFirstEditor.jsx b/admin/src/BuyFirstEditor.jsx new file mode 100644 index 0000000..73b2fd5 --- /dev/null +++ b/admin/src/BuyFirstEditor.jsx @@ -0,0 +1,345 @@ +import { useEffect, useState } from 'react'; +import { Coffee, ExternalLink, LockKeyhole, Plus, RefreshCw, Save, Trash2 } from 'lucide-react'; + +const EMPTY_CARD = { + id: '', + title: '', + description: '', + cover: '', + price: '', + currency: 'SGD', + purchaseUrl: '', + kofiProductCode: '', + content: '', + active: true, +}; + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function cleanCard(card) { + return { + id: String(card?.id || '').trim(), + title: String(card?.title || '').trim(), + description: String(card?.description || '').trim(), + cover: String(card?.cover || '').trim(), + price: String(card?.price || '').trim(), + currency: String(card?.currency || '').trim().toUpperCase(), + purchaseUrl: String(card?.purchaseUrl || '').trim(), + kofiProductCode: String(card?.kofiProductCode || '').trim(), + content: String(card?.content || ''), + active: card?.active !== false, + }; +} + +function publicCard(card) { + const cleaned = cleanCard(card); + return { + id: cleaned.id, + title: cleaned.title, + description: cleaned.description, + cover: cleaned.cover, + price: cleaned.price, + currency: cleaned.currency, + purchaseUrl: cleaned.purchaseUrl, + kofiProductCode: cleaned.kofiProductCode, + active: cleaned.active, + }; +} + +function makeCardId() { + return `buy-first-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; +} + +export default function BuyFirstEditor({ + api, + content, + setContent, + savePublicContent, + setNotice, +}) { + const [cards, setCards] = useState(() => + Array.isArray(content?.buyFirst) + ? content.buyFirst.map((card) => ({ ...EMPTY_CARD, ...clone(card) })) + : [] + ); + const [kofiUrl, setKofiUrl] = useState(content?.settings?.kofiUrl || ''); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [payments, setPayments] = useState([]); + const [paymentTotals, setPaymentTotals] = useState(null); + const [paymentLoading, setPaymentLoading] = useState(false); + + useEffect(() => { + let mounted = true; + + async function load() { + setLoading(true); + try { + const response = await fetch(`${api}/api/admin/buy-first`); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data?.message || data?.error || `API returned ${response.status}`); + + if (mounted) { + setCards(Array.isArray(data.cards) ? data.cards.map((card) => ({ ...EMPTY_CARD, ...card })) : []); + setNotice('Loaded private buy-first cards from Redis.'); + } + } catch (error) { + if (mounted) setNotice('Buy-first storage unavailable: ' + error.message); + } finally { + if (mounted) setLoading(false); + } + } + + if (api) load(); + else setNotice('No local API is configured for buy-first management.'); + + return () => { mounted = false; }; + }, [api]); + + function updateCard(index, field, value) { + setCards((current) => current.map((card, cardIndex) => + cardIndex === index ? { ...card, [field]: value } : card + )); + } + + function addCard() { + setCards((current) => [ + ...current, + { ...EMPTY_CARD, id: makeCardId(), title: 'New protected content' }, + ]); + } + + function removeCard(index) { + setCards((current) => current.filter((_, cardIndex) => cardIndex !== index)); + } + + async function save() { + if (!api) return; + + const normalized = cards.map(cleanCard); + const invalid = normalized.find((card) => + !card.id || + !card.title || + !card.purchaseUrl || + !card.kofiProductCode || + !card.content + ); + + if (invalid) { + setNotice('Every buy-first card needs an ID, title, Ko-fi purchase URL, Ko-fi product code and protected content.'); + return; + } + + setSaving(true); + try { + const privateResponse = await fetch(`${api}/api/admin/buy-first`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cards: normalized }), + }); + const privateData = await privateResponse.json().catch(() => ({})); + if (!privateResponse.ok) { + throw new Error(privateData?.message || privateData?.error || `Redis save failed (${privateResponse.status})`); + } + + const nextContent = { + ...content, + buyFirst: normalized.map(publicCard), + settings: { + ...(content?.settings || {}), + kofiUrl: kofiUrl.trim(), + }, + }; + + const saved = await savePublicContent(nextContent); + setContent(saved); + setCards(normalized); + setNotice('Buy-first cards saved. Protected text stays in Redis; only public card metadata is published.'); + } catch (error) { + setNotice('Buy-first save failed: ' + error.message); + } finally { + setSaving(false); + } + } + + async function loadPayments() { + if (!api) return; + setPaymentLoading(true); + try { + const response = await fetch(`${api}/api/admin/kofi/payments`); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data?.message || data?.error || `API returned ${response.status}`); + setPayments(Array.isArray(data.payments) ? data.payments : []); + setPaymentTotals(data.totals || null); + setNotice('Loaded Ko-fi payment events from Redis.'); + } catch (error) { + setNotice('Ko-fi payment records unavailable: ' + error.message); + } finally { + setPaymentLoading(false); + } + } + + return ( +
+
+
+
+ MONETIZATION +

Ko-fi & buy-first

+
+ +
+ +
+ +
+ Protected content stays server-side + The public site receives title/price/teaser metadata only. The actual content is returned after a verified Ko-fi purchase. +
+
+ +
+ +
+ +
+
+ PAID CONTENT +

Buy-first cards

+
+ +
+ + {loading &&
Loading protected cards...
} + + {!loading && !cards.length && ( +
No protected cards yet. Add one, then connect its Ko-fi Shop product code.
+ )} + +
+ {cards.map((card, index) => ( +
+
+
+ BUY-FIRST #{index + 1} + {card.title || 'Untitled protected card'} +
+ +
+ +
+ + + + +