+ 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.
+
Sign in to your dashboard for ready-to-post copy across X, LinkedIn, Reddit, Telegram,
+ and Discord, plus live tracking of your referrals and commissions:
+ launch.malamalabs.com/partners
+
Let’s go win. 🌍
`),
+ }).catch(() => {});
+ }
+ return NextResponse.json({ partner: updated });
+ }
+
+ if (action === 'run-payouts') {
+ const { commissionIds, kolId } = body as { commissionIds?: string[]; kolId?: string };
+ const r = await runPayoutBatch({ approvedBy: email ?? 'admin', commissionIds, kolId });
+ return NextResponse.json(r.body, { status: r.status });
+ }
+
+ if (action === 'send-email') {
+ const { to, subject, body: emailBody, partnerId, templateId, templateLabel } = body as {
+ to?: string; subject?: string; body?: string; partnerId?: string; templateId?: string; templateLabel?: string;
+ };
+ if (!to || !subject || !emailBody) {
+ return NextResponse.json({ error: 'to, subject, and body are required' }, { status: 400 });
+ }
+ const html = `
${escapeHtml(emailBody).replace(/\n/g, ' ')}
`;
+ const r = await sendEmail({ to, subject, html, text: emailBody });
+ if (!r.ok) return NextResponse.json({ error: r.error || 'Email send failed (check RESEND_API_KEY + verified domain)' }, { status: 502 });
+ // Track the send against the partner (audit + dashboard count).
+ if (partnerId) {
+ await recordKOLEmail(String(partnerId), { to, subject, templateId, templateLabel, sentBy: email ?? undefined }).catch(() => {});
+ }
+ return NextResponse.json({ ok: true, id: r.id });
+ }
+
+ if (action === 'set-amplify') {
+ const { config } = body as { config?: unknown };
+ const saved = await setAmplifyOverrides(config ?? {});
+ return NextResponse.json({ ok: true, config: saved });
+ }
+
+ return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
+ } catch (e) {
+ console.error('[partners-proxy POST]', action, e);
+ return NextResponse.json({ error: e instanceof Error ? e.message : 'Server error' }, { status: 500 });
}
-
- if (action === 'approve') {
- const { id } = body as { id: string };
- const res = await kolFetch(`/${id}`, {
- method: 'PATCH',
- body: JSON.stringify({ approved: true }),
- });
- const data = await res.json();
- return NextResponse.json(data, { status: res.status });
- }
-
- return NextResponse.json({ error: 'Unknown action' }, { status: 400 });
}
diff --git a/apps/web/src/app/api/admin/payouts/route.ts b/apps/web/src/app/api/admin/payouts/route.ts
new file mode 100644
index 0000000..87de1b7
--- /dev/null
+++ b/apps/web/src/app/api/admin/payouts/route.ts
@@ -0,0 +1,44 @@
+/**
+ * Admin payout API (external, x-admin-secret). Thin wrapper over lib/payouts-admin,
+ * which the email-session admin proxy also calls directly.
+ *
+ * GET → pending payouts overview
+ * POST → execute a batch body: { approvedBy?, commissionIds?, kolId? }
+ */
+import { NextResponse } from 'next/server'
+import { getPayoutsOverview, runPayoutBatch } from '@/lib/payouts-admin'
+
+export const runtime = 'nodejs'
+
+function checkAdmin(req: Request): boolean {
+ const secret = process.env.ADMIN_SECRET?.trim()
+ if (!secret) return false
+ return req.headers.get('x-admin-secret')?.trim() === secret
+}
+
+export async function GET(req: Request) {
+ if (!checkAdmin(req)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ try {
+ return NextResponse.json(await getPayoutsOverview())
+ } catch (e) {
+ console.error('[admin/payouts GET]', e)
+ return NextResponse.json({ error: e instanceof Error ? e.message : 'Server error' }, { status: 500 })
+ }
+}
+
+export async function POST(req: Request) {
+ if (!checkAdmin(req)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ let body: { approvedBy?: string; commissionIds?: string[]; kolId?: string }
+ try {
+ body = await req.json()
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
+ }
+ try {
+ const r = await runPayoutBatch({ approvedBy: body.approvedBy, commissionIds: body.commissionIds, kolId: body.kolId })
+ return NextResponse.json(r.body, { status: r.status })
+ } catch (e) {
+ console.error('[admin/payouts POST]', e)
+ return NextResponse.json({ error: e instanceof Error ? e.message : 'Server error' }, { status: 500 })
+ }
+}
diff --git a/apps/web/src/app/api/checkout/create-session/route.ts b/apps/web/src/app/api/checkout/create-session/route.ts
index b563358..4ddf123 100644
--- a/apps/web/src/app/api/checkout/create-session/route.ts
+++ b/apps/web/src/app/api/checkout/create-session/route.ts
@@ -16,6 +16,30 @@ export const runtime = 'nodejs'
const PRICE_CENTS = 200_000 // $2,000.00
+/**
+ * Resolve checkout amount in cents.
+ *
+ * For safe end-to-end testing without a $2,000 charge, set CHECKOUT_TEST_PRICE_CENTS
+ * (e.g. 100 = $1.00) in a test/staging env. Defaults to the real price; clamped to
+ * [50 (Stripe USD min), PRICE_CENTS] so it can never exceed the real price; logs a
+ * loud warning when active. MUST be unset in production.
+ *
+ * For SELECTIVE testing on a live/prod env, prefer a Stripe promo code instead
+ * (allow_promotion_codes is enabled below) — create a coupon for $1999 off in the
+ * Stripe dashboard so a tester pays $1 while real buyers pay full price.
+ */
+function resolveUnitAmount(): number {
+ const raw = process.env.CHECKOUT_TEST_PRICE_CENTS
+ if (!raw) return PRICE_CENTS
+ const cents = Number.parseInt(raw, 10)
+ if (!Number.isFinite(cents) || cents < 50 || cents > PRICE_CENTS) return PRICE_CENTS
+ console.warn(
+ `[checkout/create-session] ⚠️ TEST PRICE OVERRIDE active: $${(cents / 100).toFixed(2)} ` +
+ `(CHECKOUT_TEST_PRICE_CENTS=${cents}). Unset in production.`
+ )
+ return cents
+}
+
export async function POST(req: Request) {
try {
const secret = getStripeSecretKey()
@@ -58,14 +82,20 @@ export async function POST(req: Request) {
const stripe = new Stripe(secret)
+ const unitAmount = resolveUnitAmount()
+ const isTestPrice = unitAmount !== PRICE_CENTS
+
const session = await stripe.checkout.sessions.create({
mode: 'payment',
customer_email: emailNorm,
+ // Let testers apply a Stripe promo code (e.g. a $1999-off coupon → $1) for
+ // selective live testing without changing the price for real buyers.
+ allow_promotion_codes: true,
line_items: [
{
price_data: {
currency: 'usd',
- unit_amount: PRICE_CENTS,
+ unit_amount: unitAmount,
product_data: {
name: 'Mālama Genesis Hex Node License',
description: `H3 territory: ${hexId.slice(0, 18)}…`,
@@ -80,6 +110,8 @@ export async function POST(req: Request) {
hexId,
email: emailNorm,
transferToken,
+ unitAmountCents: String(unitAmount),
+ ...(isTestPrice ? { testCheckout: 'true' } : {}),
...(referrerId ? { referrerId } : {}),
},
})
diff --git a/apps/web/src/app/api/checkout/session-status/route.ts b/apps/web/src/app/api/checkout/session-status/route.ts
index f46cf3c..b92f12a 100644
--- a/apps/web/src/app/api/checkout/session-status/route.ts
+++ b/apps/web/src/app/api/checkout/session-status/route.ts
@@ -6,6 +6,7 @@ import { getSessionStatus } from '@/lib/custodial-store'
import { getStripeSecretKey } from '@/lib/stripe-server'
import { requireGenesisContract } from '@/lib/genesis-contract'
import { resolveAppUrl } from '@/lib/resolve-app-url'
+import { getExplorerTxUrl, getOpenSeaAssetUrl } from '@/lib/evm-network'
export const runtime = 'nodejs'
@@ -42,8 +43,8 @@ export async function GET(req: Request) {
custodialAddress: rec.address,
evmTokenId: rec.evmTokenId,
txHash: rec.txHash,
- explorerUrl: `https://sepolia.basescan.org/tx/${rec.txHash}`,
- openSeaUrl: `https://testnets.opensea.io/assets/base-sepolia/${GENESIS_CONTRACT}/${rec.evmTokenId}`,
+ explorerUrl: rec.txHash ? getExplorerTxUrl(rec.txHash) : undefined,
+ openSeaUrl: getOpenSeaAssetUrl(GENESIS_CONTRACT, rec.evmTokenId),
transferUrl:
rec.custody === 'server'
? `${appUrl}/custodial/transfer?claimId=${encodeURIComponent(rec.claimId)}&token=${encodeURIComponent(rec.transferToken)}`
diff --git a/apps/web/src/app/api/custodial/magic-claim/route.ts b/apps/web/src/app/api/custodial/magic-claim/route.ts
index 2cda9bd..0c82db5 100644
--- a/apps/web/src/app/api/custodial/magic-claim/route.ts
+++ b/apps/web/src/app/api/custodial/magic-claim/route.ts
@@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'
-import { issueClaim, bindEvmTokenToClaim, updateClaimTxHash } from '@/lib/genesis-claim-registry'
+import { issueClaim, bindEvmTokenToClaim, updateClaimTxHash, releaseClaim } from '@/lib/genesis-claim-registry'
import { adminMintToAddress, resolveTokenIdFromTx } from '@/lib/admin-genesis-mint'
import type { CustodialRecord } from '@/lib/custodial-store'
import {
@@ -16,6 +16,8 @@ import { resolvePendingMagicPurchase } from '@/lib/resolve-pending-magic'
export const runtime = 'nodejs'
export async function POST(req: Request) {
+ // Track a reservation so a failed mint can release it (else retries 409 forever).
+ let reservedHexId: string | null = null
try {
const body = (await req.json()) as {
didToken?: string
@@ -78,6 +80,7 @@ export async function POST(req: Request) {
}
const claimId = reserved.claim.claimId
+ reservedHexId = pending.hexId
// Broadcast the mint. Returns on tx hash; tokenId unknown until receipt.
const { txHash } = await adminMintToAddress({
@@ -136,6 +139,12 @@ export async function POST(req: Request) {
})
} catch (e) {
console.error('[magic-claim]', e)
+ // Free the reservation so the buyer can retry. releaseClaim self-guards and
+ // refuses to release a hex that actually minted (txHash/tokenId present).
+ if (reservedHexId) {
+ const r = await releaseClaim(reservedHexId).catch(() => null)
+ if (r && !r.ok) console.warn('[magic-claim] reservation kept (already minted):', reservedHexId)
+ }
const msg = e instanceof Error ? e.message : 'Claim failed'
return NextResponse.json({ error: msg }, { status: 502 })
}
diff --git a/apps/web/src/app/api/custodial/transfer/route.ts b/apps/web/src/app/api/custodial/transfer/route.ts
index 260fd22..55ce7d3 100644
--- a/apps/web/src/app/api/custodial/transfer/route.ts
+++ b/apps/web/src/app/api/custodial/transfer/route.ts
@@ -2,10 +2,10 @@ import { NextResponse } from 'next/server'
import { isAddress } from 'viem'
import { createWalletClient, http, parseAbi } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
-import { baseSepolia } from 'viem/chains'
import { getCustodialByClaimId } from '@/lib/custodial-store'
import { decryptPrivateKeyHex } from '@/lib/wallet-crypto'
import { requireGenesisContract } from '@/lib/genesis-contract'
+import { getEvmChain, getEvmRpcUrl, getExplorerTxUrl } from '@/lib/evm-network'
export const runtime = 'nodejs'
@@ -55,15 +55,10 @@ export async function POST(req: Request) {
return NextResponse.json({ error: 'Wallet mismatch' }, { status: 500 })
}
- const rpc = process.env.BASE_SEPOLIA_RPC_URL || process.env.NEXT_PUBLIC_BASE_SEPOLIA_RPC_URL
- if (!rpc) {
- return NextResponse.json({ error: 'RPC not configured' }, { status: 503 })
- }
-
const walletClient = createWalletClient({
account,
- chain: baseSepolia,
- transport: http(rpc),
+ chain: getEvmChain(),
+ transport: http(getEvmRpcUrl()),
})
const hash = await walletClient.writeContract({
@@ -76,7 +71,7 @@ export async function POST(req: Request) {
return NextResponse.json({
success: true,
txHash: hash,
- explorerUrl: `https://sepolia.basescan.org/tx/${hash}`,
+ explorerUrl: getExplorerTxUrl(hash),
})
} catch (e) {
console.error('[custodial/transfer]', e)
diff --git a/apps/web/src/app/api/data-solutions/request/route.ts b/apps/web/src/app/api/data-solutions/request/route.ts
new file mode 100644
index 0000000..5de674a
--- /dev/null
+++ b/apps/web/src/app/api/data-solutions/request/route.ts
@@ -0,0 +1,83 @@
+/**
+ * Data Solutions buyer-access request.
+ * POST { email, name?, org?, useCase?, message? } → store + notify via Resend.
+ * GET (x-admin-secret) → list requests, newest first.
+ */
+import { NextResponse } from 'next/server'
+import { randomUUID } from 'crypto'
+import { kv } from '@/lib/kv'
+import { sendEmail, emailLayout, escapeHtml, ADMIN_NOTIFY_EMAIL } from '@/lib/email'
+
+export const runtime = 'nodejs'
+
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+const INDEX = 'data:requests'
+const key = (id: string) => `data:request:${id}`
+
+type DataRequest = { id: string; email: string; name: string; org: string; useCase: string; message: string; createdAt: number }
+
+export async function POST(req: Request) {
+ let body: Record
+ try {
+ body = (await req.json()) as Record
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
+ }
+
+ const email = String(body.email ?? '').trim().toLowerCase()
+ if (!email) return NextResponse.json({ error: 'Email is required' }, { status: 400 })
+ if (!EMAIL_RE.test(email)) return NextResponse.json({ error: 'Please enter a valid email' }, { status: 400 })
+
+ const reqRow: DataRequest = {
+ id: randomUUID(),
+ email: email.slice(0, 200),
+ name: String(body.name ?? '').trim().slice(0, 120),
+ org: String(body.org ?? '').trim().slice(0, 200),
+ useCase: String(body.useCase ?? '').trim().slice(0, 120),
+ message: String(body.message ?? '').trim().slice(0, 2000),
+ createdAt: Date.now(),
+ }
+
+ try {
+ await kv.set(key(reqRow.id), reqRow)
+ await kv.sadd(INDEX, reqRow.id)
+ } catch (e) {
+ console.error('[data-solutions/request] persist failed', e)
+ return NextResponse.json({ error: 'Could not save — please try again' }, { status: 500 })
+ }
+
+ await Promise.allSettled([
+ sendEmail({
+ to: ADMIN_NOTIFY_EMAIL,
+ replyTo: reqRow.email,
+ subject: `Data Solutions request — ${reqRow.name || reqRow.email}${reqRow.org ? ` (${reqRow.org})` : ''}`,
+ html: emailLayout('New Data Solutions buyer request', `
+
We received your request for access to Mālama Labs Data Solutions. Our data team will
+ reach out to scope datasets, coverage, and API access for your use case.
+
Mālama Labs — the Physical Data Oracle for environmental markets.
`),
+ }),
+ ])
+
+ return NextResponse.json({ ok: true })
+}
+
+export async function GET(req: Request) {
+ const secret = process.env.ADMIN_SECRET?.trim()
+ if (!secret || req.headers.get('x-admin-secret')?.trim() !== secret) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+ const ids = await kv.smembers(INDEX)
+ const rows = (await Promise.all(ids.map((id) => kv.get(key(id)).catch(() => null)))).filter(Boolean) as DataRequest[]
+ rows.sort((a, b) => b.createdAt - a.createdAt)
+ return NextResponse.json({ requests: rows, count: rows.length })
+}
diff --git a/apps/web/src/app/api/nft/claim/route.ts b/apps/web/src/app/api/nft/claim/route.ts
index cbd51f9..baa4a60 100644
--- a/apps/web/src/app/api/nft/claim/route.ts
+++ b/apps/web/src/app/api/nft/claim/route.ts
@@ -6,9 +6,13 @@ import {
getStats,
updateClaimTxHash,
bindEvmTokenToClaim,
+ releaseClaim,
} from '@/lib/genesis-claim-registry'
import { getCustodialRecordsByEmail } from '@/lib/custodial-store'
import { upsertUserAccount } from '@/lib/user-account'
+import nativeHexesData from '@/data/genesis-native-hexes.json'
+
+const NATIVE_HEX_SET = new Set(Object.keys(nativeHexesData as Record))
// ─────────────────────────────────────────────────────────────────────────────
export async function POST(req: Request) {
@@ -29,6 +33,14 @@ export async function POST(req: Request) {
)
}
+ // Native-reserved hexes are held for Native Tribes first — not publicly claimable.
+ if (NATIVE_HEX_SET.has(hexId)) {
+ return NextResponse.json(
+ { error: 'This hex is on tribal land and reserved for Native Tribes', nativeReserved: true },
+ { status: 403 },
+ )
+ }
+
const result = await issueClaim(hexId, chain, buyerAddress)
if (!result.ok) {
if (result.existing) {
@@ -133,3 +145,24 @@ export async function GET(req: Request) {
...(claim ?? {}),
})
}
+
+// ─── Admin: release a (non-minted) hex reservation ──────────────────────────
+// Clears a stuck/abandoned reservation so the hex can be claimed again.
+// Guarded by x-admin-secret. Refuses to release a minted hex.
+// curl -X DELETE "$URL/api/nft/claim?hexId=" -H "x-admin-secret: $ADMIN_SECRET"
+export async function DELETE(req: Request) {
+ const secret = process.env.ADMIN_SECRET?.trim()
+ if (!secret || req.headers.get('x-admin-secret')?.trim() !== secret) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+ const { searchParams } = new URL(req.url)
+ const hexId = searchParams.get('hexId')
+ if (!hexId) {
+ return NextResponse.json({ error: 'hexId query param required' }, { status: 400 })
+ }
+ const result = await releaseClaim(hexId)
+ if (!result.ok) {
+ return NextResponse.json({ error: result.reason ?? 'Could not release' }, { status: 409 })
+ }
+ return NextResponse.json({ ok: true, released: hexId })
+}
diff --git a/apps/web/src/app/api/partners/apply/route.ts b/apps/web/src/app/api/partners/apply/route.ts
index 48294d8..1bcc7ce 100644
--- a/apps/web/src/app/api/partners/apply/route.ts
+++ b/apps/web/src/app/api/partners/apply/route.ts
@@ -8,10 +8,20 @@
*/
import { NextResponse } from 'next/server'
+import { cookies } from 'next/headers'
import { registerKOL, getKOLByWallet, buildReferralUrl, buildVanityUrl } from '@/lib/kol-registry'
+import { sendEmail, emailLayout, escapeHtml, ADMIN_NOTIFY_EMAIL } from '@/lib/email'
+import { parseEmailSessionToken } from '@/lib/email-session'
+import { makeUserId } from '@/lib/user-account'
export const runtime = 'nodejs'
+const clean = (v: unknown): string | undefined => {
+ if (typeof v !== 'string') return undefined
+ const t = v.trim().replace(/^@/, '')
+ return t || undefined
+}
+
function slugify(name: string): string {
return name
.toLowerCase()
@@ -29,7 +39,15 @@ export async function POST(req: Request) {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
- const { displayName, email, walletAddress, twitterHandle, bio, promoMethod } = body
+ const { displayName, email, walletAddress, twitterHandle, bio, promoMethod, telegram, linkedin, reddit } = body
+
+ // Associate with the signed-in account when present (email session cookie).
+ let sessionEmail: string | undefined
+ try {
+ const raw = (await cookies()).get('malama_email_session')?.value
+ if (raw) sessionEmail = parseEmailSessionToken(raw)?.email?.toLowerCase()
+ } catch { /* no session — public application */ }
+ const resolvedEmail = sessionEmail ?? (email ? String(email).trim().toLowerCase() : undefined)
if (!displayName || typeof displayName !== 'string' || !displayName.trim()) {
return NextResponse.json({ error: 'displayName is required' }, { status: 400 })
@@ -58,15 +76,33 @@ export async function POST(req: Request) {
id,
walletAddress: String(walletAddress),
displayName: String(displayName).trim(),
- email: email ? String(email).trim().toLowerCase() : undefined,
- twitterHandle: twitterHandle ? String(twitterHandle).replace(/^@/, '') : undefined,
+ email: resolvedEmail,
+ twitterHandle: clean(twitterHandle),
+ telegram: clean(telegram),
+ linkedin: clean(linkedin),
+ reddit: clean(reddit),
bio: bio ? String(bio).trim() : undefined,
+ promoMethod: promoMethod ? String(promoMethod).trim() : undefined,
+ userId: resolvedEmail ? makeUserId(resolvedEmail) : undefined,
commissionBps: 1000, // default 10% — admin can adjust
approved: false, // requires admin approval
})
console.log(`[partners/apply] New application: ${partner.id} (${walletAddress})`)
+ // Notify admin to review/approve. Fire-and-continue.
+ await sendEmail({
+ to: ADMIN_NOTIFY_EMAIL,
+ replyTo: resolvedEmail,
+ subject: `New partner application — ${partner.displayName}`,
+ html: emailLayout('New partner application (pending approval)', `
+
Name: ${escapeHtml(partner.displayName)}
+
Wallet: ${escapeHtml(String(walletAddress))}
+ ${resolvedEmail ? `
Email: ${escapeHtml(resolvedEmail)}
` : ''}
+ ${promoMethod ? `
How they'll promote: ${escapeHtml(String(promoMethod))}
`),
+ }).catch(() => {})
+
return NextResponse.json({
id: partner.id,
referralUrl: buildReferralUrl(partner.id),
diff --git a/apps/web/src/app/api/partners/me/route.ts b/apps/web/src/app/api/partners/me/route.ts
index 16d8145..f85802e 100644
--- a/apps/web/src/app/api/partners/me/route.ts
+++ b/apps/web/src/app/api/partners/me/route.ts
@@ -10,6 +10,7 @@
import { NextResponse } from 'next/server'
import { getKOLByWallet, getKOLStats, buildReferralUrl, buildVanityUrl } from '@/lib/kol-registry'
+import { getAmplifyOverrides } from '@/lib/amplify-config'
export const runtime = 'nodejs'
@@ -43,5 +44,6 @@ export async function GET(req: Request) {
...stats,
referralUrl: buildReferralUrl(partner.id),
vanityUrl: buildVanityUrl(partner.id),
+ amplifyOverrides: await getAmplifyOverrides(),
})
}
diff --git a/apps/web/src/app/api/sensors/quote/route.ts b/apps/web/src/app/api/sensors/quote/route.ts
new file mode 100644
index 0000000..bdb2885
--- /dev/null
+++ b/apps/web/src/app/api/sensors/quote/route.ts
@@ -0,0 +1,84 @@
+/**
+ * Sensor quote/lead capture.
+ * POST { name, email, org?, message? } → store a lead in KV.
+ * GET (x-admin-secret) → list leads, newest first.
+ */
+import { NextResponse } from 'next/server'
+import { randomUUID } from 'crypto'
+import { kv } from '@/lib/kv'
+import { sendEmail, emailLayout, escapeHtml, ADMIN_NOTIFY_EMAIL } from '@/lib/email'
+
+export const runtime = 'nodejs'
+
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+const INDEX = 'sensors:quotes'
+const key = (id: string) => `sensors:quote:${id}`
+
+type Quote = { id: string; name: string; email: string; org: string; message: string; createdAt: number }
+
+export async function POST(req: Request) {
+ let body: Record
+ try {
+ body = (await req.json()) as Record
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
+ }
+
+ const name = String(body.name ?? '').trim()
+ const email = String(body.email ?? '').trim().toLowerCase()
+ if (!name || !email) return NextResponse.json({ error: 'Name and email are required' }, { status: 400 })
+ if (!EMAIL_RE.test(email)) return NextResponse.json({ error: 'Please enter a valid email' }, { status: 400 })
+
+ const quote: Quote = {
+ id: randomUUID(),
+ name: name.slice(0, 120),
+ email: email.slice(0, 200),
+ org: String(body.org ?? '').trim().slice(0, 200),
+ message: String(body.message ?? '').trim().slice(0, 2000),
+ createdAt: Date.now(),
+ }
+
+ try {
+ await kv.set(key(quote.id), quote)
+ await kv.sadd(INDEX, quote.id)
+ } catch (e) {
+ console.error('[sensors/quote] persist failed', e)
+ return NextResponse.json({ error: 'Could not save — please try again' }, { status: 500 })
+ }
+
+ // Notify the team + confirm to the lead. Never block the response on email.
+ await Promise.allSettled([
+ sendEmail({
+ to: ADMIN_NOTIFY_EMAIL,
+ replyTo: quote.email,
+ subject: `New sensor quote — ${quote.name}${quote.org ? ` (${quote.org})` : ''}`,
+ html: emailLayout('New sensor quote request', `
+
Name: ${escapeHtml(quote.name)}
+
Email: ${escapeHtml(quote.email)}
+ ${quote.org ? `
Organization: ${escapeHtml(quote.org)}
` : ''}
+ ${quote.message ? `
Message: ${escapeHtml(quote.message)}
` : ''}`),
+ }),
+ sendEmail({
+ to: quote.email,
+ subject: 'Thanks for your interest in Mālama sensors',
+ html: emailLayout(`Thanks, ${escapeHtml(quote.name.split(' ')[0] || quote.name)}`, `
+
We received your request about the Mālama sensor system. The sensors are in active
+ development — our team will reach out as they move toward deployment.
Verifiable environmental data, signed at the source.
+
License hyper-local datasets generated by a decentralized network of hardware-signed sensors. Every reading is cryptographically signed at the device and anchored on chain, so it is auditable before it ever reaches your application.
Organizations across climate, finance, infrastructure, and AI build on crowdsourced, hardware-verified data. The same Proof-of-Truth pipeline serves every sector below.
+
+ {USE_CASES.map((u) => (
+
+
+
{u.title}
+
{u.body}
+
+ ))}
+
+
+
+
+ {/* 02 · ADVANTAGE */}
+
+
+
02 · The Buyer Advantage
+
Legacy brokers measure top-down. We measure at the edge.
+
Traditional data providers rely on sparse, expensive infrastructure. A decentralized network crowdsources collection through hardware operators, producing higher density, lower latency, and verifiability that legacy feeds cannot offer.
+
+ {ADVANTAGES.map((a) => (
+
+
+
{a.title}
+
{a.body}
+
+ ))}
+
+
+
+
+ {/* 03 · MARKET OUTLOOK */}
+
+
+
03 · Market Outlook
+
Demand for verifiable data, by sector.
+
As autonomous systems and smart contracts proliferate, the need for sensor-verified real-world data rises across buyer segments. The figures below are modeled, not measured.
+
+
+
+
+ {/* 04 · PROCESS */}
+
+
+
04 · Acquisition Process
+
From query to API in four steps.
+
+ {PROCESS.map((s) => (
+
+
{s.n}
+
{s.h}
+
{s.p}
+
+ ))}
+
+
+
+
+ {/* CTA */}
+
+
+
+
Ready to build on verifiable world data?
+
Request buyer access and our data team will scope datasets, coverage, and API access for your use case.
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/app/favicon.ico b/apps/web/src/app/favicon.ico
new file mode 100644
index 0000000..02d6810
Binary files /dev/null and b/apps/web/src/app/favicon.ico differ
diff --git a/apps/web/src/app/icon.png b/apps/web/src/app/icon.png
new file mode 100644
index 0000000..216e6e0
Binary files /dev/null and b/apps/web/src/app/icon.png differ
diff --git a/apps/web/src/app/launch/LaunchClient.tsx b/apps/web/src/app/launch/LaunchClient.tsx
index 29b0df5..3de0594 100644
--- a/apps/web/src/app/launch/LaunchClient.tsx
+++ b/apps/web/src/app/launch/LaunchClient.tsx
@@ -5,6 +5,7 @@ import Link from 'next/link'
import { useCallback, useEffect, useState } from 'react'
import { Loader2, Wallet, ExternalLink, CheckCircle2, AlertCircle } from 'lucide-react'
import { useMagic } from '@/components/magic/MagicProvider'
+import { getExplorerTxUrl, getOpenSeaAssetUrl } from '@/lib/evm-network'
const GENESIS_CONTRACT = process.env.NEXT_PUBLIC_GENESIS_CONTRACT_ADDRESS ?? ''
@@ -116,14 +117,13 @@ export default function LaunchClient({ hasMagicPublishableKey }: { hasMagicPubli
if (claimResult) {
const openSea =
- GENESIS_CONTRACT &&
- `https://testnets.opensea.io/assets/base-sepolia/${GENESIS_CONTRACT}/${claimResult.evmTokenId}`
+ GENESIS_CONTRACT && getOpenSeaAssetUrl(GENESIS_CONTRACT, claimResult.evmTokenId)
return (
{claimResult.claimId}
- Your Genesis NFT is in your Magic wallet on Base Sepolia. You can connect this app or any wallet UI that
+ Your Genesis NFT is in your Magic wallet on Base. You can connect this app or any wallet UI that
supports Magic to manage it.