diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..b4d690d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,51 @@ +{ + "permissions": { + "allow": [ + "Bash(npm install:*)", + "Bash(npm ci:*)", + "Bash(npm run:*)", + "Bash(npm test:*)", + "Bash(pnpm install:*)", + "Bash(pnpm run:*)", + "Bash(pnpm test:*)", + "Bash(pnpm lint:*)", + "Bash(pnpm build:*)", + "Bash(pnpm dev:*)", + "Bash(pnpm typecheck:*)", + "Bash(npx hardhat:*)", + "Bash(aiken:*)", + "Bash(pytest:*)", + "Bash(python -m pytest:*)", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(git branch:*)", + "Bash(git fetch:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git checkout:*)", + "Bash(git switch:*)", + "Bash(git restore:*)", + "Bash(git stash:*)", + "Bash(ls:*)", + "Bash(cat:*)", + "Bash(rg:*)", + "Bash(grep:*)", + "Bash(find:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(wc:*)", + "Bash(tree:*)", + "Bash(echo:*)", + "Bash(which:*)", + "Bash(pwd)" + ], + "deny": [ + "Bash(rm -rf:*)", + "Bash(git push --force:*)", + "Bash(git push -f:*)", + "Bash(git reset --hard:*)" + ] + } +} diff --git a/.env.example b/.env.example index decba59..1e20f4b 100644 --- a/.env.example +++ b/.env.example @@ -11,8 +11,23 @@ KAFKA_BOOTSTRAP=localhost:9092 # Cardano (Blockfrost API for Pre-Prod) BLOCKFROST_PROJECT_ID=preprodYourProjectId -# EVM (Base Sepolia RPC) +# ── EVM network switch (lib/evm-network.ts) ────────────────────────────────── +# THE go-live switch. "base" = Base mainnet (real USDC/NFTs); "base-sepolia" = +# testnet (default). Governs chain, RPC, USDC token, explorer + OpenSea links, +# and which network the mint/transfer flows enforce in the wallet. +NEXT_PUBLIC_EVM_NETWORK=base-sepolia + +# RPCs (public defaults work but are rate-limited; set dedicated RPCs for prod). BASE_SEPOLIA_RPC_URL=https://sepolia.base.org +NEXT_PUBLIC_BASE_SEPOLIA_RPC_URL=https://sepolia.base.org +# Used when NEXT_PUBLIC_EVM_NETWORK=base +BASE_RPC_URL=https://mainnet.base.org +NEXT_PUBLIC_BASE_RPC_URL=https://mainnet.base.org + +# Genesis ERC-721 for the ACTIVE network. Mainnet (Base 8453): +# 0x93c26c9B9fe702D7944aE62fc54e451d4470F21D +# Set this to match NEXT_PUBLIC_EVM_NETWORK (mainnet address when on "base"). +NEXT_PUBLIC_GENESIS_CONTRACT_ADDRESS=0x93c26c9B9fe702D7944aE62fc54e451d4470F21D # Contract deployment keys (use test keys only) AI_VALIDATOR_KEY=0x_your_test_private_key @@ -36,14 +51,13 @@ AUTH0_SECRET= # Optional API audience (resource identifier) for access tokens / Magic IDP alignment AUTH0_AUDIENCE= -# Genesis NFT (EVM) — used by web mint + card/custodial flow -NEXT_PUBLIC_GENESIS_CONTRACT_ADDRESS=0x_your_genesis_validator +# (Genesis contract address + Base RPCs are configured in the EVM network switch +# section near the top of this file — see NEXT_PUBLIC_EVM_NETWORK.) # Card checkout (Stripe) + custodial wallet — default `magic` (Magic Email OTP → embedded wallet). # Use `server` for legacy server-generated keys + transfer link only. MALAMA_CARD_CUSTODY=magic NEXT_PUBLIC_MAGIC_API_KEY=pk_live_or_test_your_magic_publishable_key -NEXT_PUBLIC_BASE_SEPOLIA_RPC_URL=https://sepolia.base.org # Magic Secret API key (server) — validates DID tokens from /launch; same key family as Magic Express if applicable MAGIC_SECRET_KEY= @@ -75,3 +89,24 @@ MAGIC_IDP_AUDIENCE=your-app-audience MAGIC_IDP_JWKS_URI=https://your-auth-provider.com/.well-known/jwks.json # Protects admin Magic routes (x-malama-ops-key or Authorization: Bearer) MALAMA_OPS_KEY=change_me_long_random_secret + +# ── Partner referral payouts (USDC on Base) ────────────────────────────────── +# Admin-approved, system-sent commissions. SAFE BY DEFAULT: dry-run ON and +# testnet selected until you explicitly opt in. See lib/usdc-payout.ts. +# +# Dedicated payout HOT WALLET key — fund with operating float ONLY, never a +# treasury/multisig. Server env only; never commit. Needs USDC + a little ETH +# for gas on the selected network. +PAYOUT_WALLET_PRIVATE_KEY=0x_dedicated_payout_wallet_key +# "base-sepolia" (default, testnet) | "base" (mainnet, real money) +PAYOUT_NETWORK=base-sepolia +# Base mainnet RPC (used only when PAYOUT_NETWORK=base) +BASE_RPC_URL=https://mainnet.base.org +# Must be exactly "false" to send real USDC. Anything else = dry run (no chain tx). +PAYOUT_DRY_RUN=true +# Hard ceiling per payout batch in USD — batches above this are rejected. +PAYOUT_MAX_BATCH_USD=5000 +# Admin API secret (x-admin-secret) — required for /api/admin/* including payouts. +ADMIN_SECRET=change_me_long_random_secret +# Comma-separated admin emails allowed into the admin dashboard. +ADMIN_EMAILS=tyler@malamaproject.org diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba199b3..824ad0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,14 @@ name: Continuous Integration on: - push: - branches: [ "main", "develop" ] - pull_request: - branches: [ "main", "develop" ] + # Auto-triggers disabled. GitHub Actions is not executing jobs on this account + # (every run fails at startup in ~2s with no steps/logs). Re-enable CI by + # restoring the push/pull_request triggers below once Actions/billing is resolved: + # push: + # branches: [ "main", "develop" ] + # pull_request: + # branches: [ "main", "develop" ] + workflow_dispatch: jobs: cardano-contracts: diff --git a/.github/workflows/deploy-testnet.yml b/.github/workflows/deploy-testnet.yml index 5b3595f..833ac9c 100644 --- a/.github/workflows/deploy-testnet.yml +++ b/.github/workflows/deploy-testnet.yml @@ -1,9 +1,12 @@ name: Deploy to Testnet on: - push: - branches: - - develop + # Auto-trigger disabled (Actions not executing on this account). Re-enable by + # restoring the develop push trigger below once Actions/billing is resolved: + # push: + # branches: + # - develop + workflow_dispatch: permissions: contents: read diff --git a/.gitignore b/.gitignore index baeffb2..9fe62c7 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,9 @@ docker-volumes/ .env*.local *.tsbuildinfo + +# Geo seed data cache (large GeoJSON) +scripts/.cache/ +apps/web/src/data/regions.json.bak +!apps/web/public/sensors/*.png +!apps/web/public/sensors/*.jpg diff --git a/apps/web/public/logo.svg b/apps/web/public/logo.svg new file mode 100644 index 0000000..3e5d19a --- /dev/null +++ b/apps/web/public/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/web/public/sensors/1_1f6a1ba6.png b/apps/web/public/sensors/1_1f6a1ba6.png new file mode 100644 index 0000000..a7e51e7 Binary files /dev/null and b/apps/web/public/sensors/1_1f6a1ba6.png differ diff --git a/apps/web/public/sensors/2_c58834b3.png b/apps/web/public/sensors/2_c58834b3.png new file mode 100644 index 0000000..c81e6dd Binary files /dev/null and b/apps/web/public/sensors/2_c58834b3.png differ diff --git a/apps/web/public/sensors/3_b0588513.png b/apps/web/public/sensors/3_b0588513.png new file mode 100644 index 0000000..2dd7683 Binary files /dev/null and b/apps/web/public/sensors/3_b0588513.png differ diff --git a/apps/web/public/sensors/4_deda1c38.png b/apps/web/public/sensors/4_deda1c38.png new file mode 100644 index 0000000..06a6a6d Binary files /dev/null and b/apps/web/public/sensors/4_deda1c38.png differ diff --git a/apps/web/public/sensors/5_8e682334.png b/apps/web/public/sensors/5_8e682334.png new file mode 100644 index 0000000..58c8bc2 Binary files /dev/null and b/apps/web/public/sensors/5_8e682334.png differ diff --git a/apps/web/public/sensors/field-hero.png b/apps/web/public/sensors/field-hero.png new file mode 100644 index 0000000..0754df2 Binary files /dev/null and b/apps/web/public/sensors/field-hero.png differ diff --git a/apps/web/public/sensors/sensor-hub.jpg b/apps/web/public/sensors/sensor-hub.jpg new file mode 100644 index 0000000..3d1c887 Binary files /dev/null and b/apps/web/public/sensors/sensor-hub.jpg differ diff --git a/apps/web/public/sensors/sensor-kit.jpg b/apps/web/public/sensors/sensor-kit.jpg new file mode 100644 index 0000000..d440615 Binary files /dev/null and b/apps/web/public/sensors/sensor-kit.jpg differ diff --git a/apps/web/public/sensors/sign-the-measurement.png b/apps/web/public/sensors/sign-the-measurement.png new file mode 100644 index 0000000..9d751f5 Binary files /dev/null and b/apps/web/public/sensors/sign-the-measurement.png differ diff --git a/apps/web/src/app/admin/partners/page.tsx b/apps/web/src/app/admin/partners/page.tsx index 2238155..fd6d85e 100644 --- a/apps/web/src/app/admin/partners/page.tsx +++ b/apps/web/src/app/admin/partners/page.tsx @@ -33,6 +33,8 @@ interface KOLStats { totalEarned: number; pendingEarned: number; paidEarned: number; + emailsSent?: number; + lastEmailAt?: number; } interface PartnerWithStats extends KOLPartner { @@ -47,6 +49,24 @@ interface CopyTemplate { body: string; } +interface PayoutGroup { + kolId: string; + displayName: string; + walletAddress: string | null; + walletValid: boolean; + walletReason?: string; + commissionCount: number; + totalUsd: number; +} + +interface PayoutsData { + config: { network: string; dryRun: boolean; maxBatchUsd: number; hasWallet: boolean }; + hotWallet: { address: string; usdc: number; eth: string } | null; + totalPendingUsd: number; + pendingCount: number; + groups: PayoutGroup[]; +} + // ── Helpers ─────────────────────────────────────────────────────────────────── const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? 'https://launch.malamalabs.com'; @@ -76,6 +96,96 @@ export default function AdminPartnersPage() { const [showInvite, setShowInvite] = useState(false); const [selectedTemplate, setSelectedTemplate] = useState(null); const [copiedId, setCopiedId] = useState(null); + const [payouts, setPayouts] = useState(null); + const [payoutBusy, setPayoutBusy] = useState(null); + const [payoutMsg, setPayoutMsg] = useState(null); + + const [amplify, setAmplify] = useState<{ hashtag?: string; posts?: Record }>({}); + const [amplifySaving, setAmplifySaving] = useState(false); + const [amplifyMsg, setAmplifyMsg] = useState(null); + + // Send onboarding email (Resend) + const [emailTo, setEmailTo] = useState(''); + const [emailName, setEmailName] = useState(''); + const [emailRef, setEmailRef] = useState(''); + const [emailCommission, setEmailCommission] = useState(10); + const [emailSubject, setEmailSubject] = useState(''); + const [emailBody, setEmailBody] = useState(''); + const [emailBusy, setEmailBusy] = useState(false); + const [emailMsg, setEmailMsg] = useState(null); + + function pickEmailPartner(id: string) { + const p = partners.find((x) => x.id === id); + if (!p) return; + setEmailName(p.displayName); + setEmailTo(p.email ?? ''); + setEmailRef(p.referralUrls?.base ?? referralUrl(p.id)); + setEmailCommission((p.commissionBps ?? 1000) / 100); + } + function applyEmailTemplate(tpl: CopyTemplate | undefined) { + if (!tpl) return; + const fill = (s: string) => + s.replaceAll('[NAME]', emailName || 'there').replaceAll('[REFERRAL_URL]', emailRef || '').replaceAll('[COMMISSION]', String(emailCommission)); + setEmailSubject(fill(tpl.subject ?? '')); + setEmailBody(fill(tpl.body)); + } + async function sendOnboardingEmail() { + if (!emailTo || !emailSubject || !emailBody) { setEmailMsg('✕ Recipient, subject, and body are required'); return; } + setEmailBusy(true); setEmailMsg(null); + try { + const res = await fetch('/api/admin/partners-proxy', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'send-email', to: emailTo, subject: emailSubject, body: emailBody }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Send failed'); + setEmailMsg('✓ Sent to ' + emailTo); + } catch (e) { + setEmailMsg('✕ ' + (e instanceof Error ? e.message : 'Send failed')); + } finally { setEmailBusy(false); } + } + + const loadPayouts = () => + fetch('/api/admin/partners-proxy?action=payouts') + .then((r) => r.json()) + .then((d) => { if (!d.error) setPayouts(d as PayoutsData); }) + .catch(() => {}); + + const loadAmplify = () => + fetch('/api/admin/partners-proxy?action=amplify-config') + .then((r) => r.json()) + .then((d) => { if (!d.error) setAmplify(d.config ?? {}); }) + .catch(() => {}); + + const AMPLIFY_CHANNELS: { key: string; label: string; placeholder: string }[] = [ + { key: 'x', label: 'X / Twitter', placeholder: 'Tweet copy — include [REFERRAL_URL] via the auto-injected link' }, + { key: 'reddit', label: 'Reddit (link-post title)', placeholder: 'Reddit post title' }, + { key: 'linkedin', label: 'LinkedIn', placeholder: 'LinkedIn post commentary' }, + { key: 'telegram', label: 'Telegram', placeholder: 'Telegram message' }, + { key: 'discord', label: 'Discord', placeholder: 'Discord message' }, + ]; + + const setAmplifyPost = (key: string, val: string) => + setAmplify((a) => ({ ...a, posts: { ...(a.posts ?? {}), [key]: val } })); + + const saveAmplify = async () => { + setAmplifySaving(true); + setAmplifyMsg(null); + try { + const res = await fetch('/api/admin/partners-proxy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'set-amplify', config: amplify }), + }); + const data = await res.json(); + if (!res.ok) setAmplifyMsg(`✕ ${data.error ?? 'Save failed'}`); + else { setAmplify(data.config ?? {}); setAmplifyMsg('Saved — every partner now sees this copy.'); } + } catch (e) { + setAmplifyMsg(`✕ ${String(e)}`); + } finally { + setAmplifySaving(false); + } + }; // ── Load data ── useEffect(() => { @@ -90,6 +200,8 @@ export default function AdminPartnersPage() { }) .catch((e) => setError(String(e))) .finally(() => setLoading(false)); + loadPayouts(); + loadAmplify(); }, []); const copy = (text: string, id: string) => { @@ -112,12 +224,55 @@ export default function AdminPartnersPage() { } }; + const runPayout = async (group?: PayoutGroup) => { + if (!payouts) return; + const dry = payouts.config.dryRun; + const what = group + ? `$${group.totalUsd} to ${group.displayName}` + : `all pending ($${payouts.totalPendingUsd})`; + if (!window.confirm(`${dry ? '[DRY RUN] ' : '⚠️ LIVE — '}Send ${what} in USDC on ${payouts.config.network}?`)) return; + setPayoutBusy(group?.kolId ?? 'all'); + setPayoutMsg(null); + try { + const res = await fetch('/api/admin/partners-proxy', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'run-payouts', ...(group ? { kolId: group.kolId } : {}) }), + }); + const data = await res.json(); + if (!res.ok) { + setPayoutMsg(`✕ ${data.error ?? 'Payout failed'}`); + } else { + setPayoutMsg( + `${data.dryRun ? '[DRY RUN] ' : ''}Paid ${data.paidCount} · failed ${data.failedCount} · skipped ${data.skippedCount} · $${data.paidUsd}` + ); + await loadPayouts(); + fetch('/api/admin/partners-proxy?action=list') + .then((r) => r.json()) + .then((d) => { if (!d.error) setPartners((d.partners ?? d.kols ?? []) as PartnerWithStats[]); }) + .catch(() => {}); + } + } catch (e) { + setPayoutMsg(`✕ ${String(e)}`); + } finally { + setPayoutBusy(null); + } + }; + if (loading) return

Loading partner registry…

; if (error === 'Forbidden') return

Access denied — admin only.

; if (error) return

Error: {error}

; const pending = partners.filter((p) => !p.approved); const active = partners.filter((p) => p.approved); + const totals = partners.reduce( + (acc, p) => ({ + clicks: acc.clicks + (p.stats?.clicks ?? 0), + conversions: acc.conversions + (p.stats?.conversions ?? 0), + earned: acc.earned + (p.stats?.totalEarned ?? 0), + }), + { clicks: 0, conversions: 0, earned: 0 }, + ); return ( @@ -136,6 +291,99 @@ export default function AdminPartnersPage() { + {/* ── Activity overview ── */} +
+ {[ + { label: 'Partners', value: String(partners.length) }, + { label: 'Active', value: String(active.length) }, + { label: 'Total clicks', value: totals.clicks.toLocaleString() }, + { label: 'Conversions', value: totals.conversions.toLocaleString() }, + { label: 'Commissions', value: `$${totals.earned.toFixed(0)}` }, + ].map((s) => ( +
+

{s.label}

+

{s.value}

+
+ ))} +
+ + {/* ── Payouts ── */} + {payouts && ( +
+
+ + {payouts.config.dryRun ? 'DRY RUN' : '● LIVE'} + + network: {payouts.config.network} + cap: ${payouts.config.maxBatchUsd}/batch + + wallet: {payouts.hotWallet + ? `${payouts.hotWallet.address.slice(0, 6)}…${payouts.hotWallet.address.slice(-4)} · ${payouts.hotWallet.usdc} USDC · ${Number(payouts.hotWallet.eth).toFixed(4)} ETH` + : (payouts.config.hasWallet ? 'configured' : '⚠ not configured')} + +
+ + {payoutMsg && ( +

+ {payoutMsg} +

+ )} + +

+ {payouts.pendingCount} pending commission{payouts.pendingCount === 1 ? '' : 's'} · ${payouts.totalPendingUsd} total +

+ + {payouts.groups.length === 0 && ( +

No pending commissions to pay.

+ )} + + {payouts.groups.map((g) => ( +
+
+

{g.displayName}

+

+ {g.commissionCount} sale{g.commissionCount === 1 ? '' : 's'} ·{' '} + {g.walletValid + ? `${g.walletAddress!.slice(0, 6)}…${g.walletAddress!.slice(-4)}` + : ⚠ {g.walletReason}} +

+
+
+ ${g.totalUsd} + +
+
+ ))} + + {payouts.groups.some((g) => g.walletValid) && ( + + )} +
+ )} + {/* ── Pending approvals ── */} {pending.length > 0 && (
@@ -171,6 +419,84 @@ export default function AdminPartnersPage() {
{/* ── Approved copy templates ── */} + {/* ── Amplify messaging editor ── */} +
+

+ Edit the ready-to-post copy partners see in their dashboard's Amplify toolkit. Leave a field blank to use + the built-in default. Each partner's referral link is appended automatically. +

+
+ + setAmplify((a) => ({ ...a, hashtag: e.target.value }))} + placeholder="#DePIN" + style={{ width: '100%', maxWidth: 240, background: '#0d0d0d', border: '1px solid #222', borderRadius: 6, padding: '8px 10px', color: '#e8e8e8', fontSize: 13 }} + /> +
+ {AMPLIFY_CHANNELS.map((ch) => ( +
+ +