Add sponsors system with time slider, LNbits invoices, and UX improvements
- Sponsors table, LNbits createInvoice, webhook handler - Sponsor routes: create, homepage, list, my-ads, click, extend, check-payment - Admin routes for sponsor management - Frontend: SponsorForm, SponsorTimeSlider, SponsorCard, SponsorsSection - Sponsors page, My Ads page, homepage sponsor block - Header login dropdown with My Ads, Create Sponsor - Transactions integration for sponsor payments - View/click tracking - OG meta fetch for sponsor images - Sponsor modal spacing, invoice polling fallback - Remove Lightning address and Category fields from sponsor form Made-with: Cursor
This commit is contained in:
@@ -53,3 +53,12 @@ DEPOSIT_LNURLP=https://yourdomain.com/.well-known/lnurlp/faucet
|
|||||||
|
|
||||||
# Cashu redeem (optional; default: https://cashu-redeem.azzamo.net)
|
# Cashu redeem (optional; default: https://cashu-redeem.azzamo.net)
|
||||||
# CASHU_REDEEM_API_URL=https://cashu-redeem.azzamo.net
|
# CASHU_REDEEM_API_URL=https://cashu-redeem.azzamo.net
|
||||||
|
|
||||||
|
# Sponsors
|
||||||
|
BASE_SPONSOR_PRICE_PER_DAY=200
|
||||||
|
SPONSOR_MAX_ACTIVE_PER_USER=5
|
||||||
|
SPONSOR_MAX_VISIBLE=6
|
||||||
|
# Comma-separated hex pubkeys for admin API
|
||||||
|
# ADMIN_PUBKEYS=abc123...,def456...
|
||||||
|
# Public API URL for LNbits webhook (e.g. https://api.example.com)
|
||||||
|
# PUBLIC_API_URL=https://api.example.com
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ export const config = {
|
|||||||
depositLightningAddress: process.env.DEPOSIT_LIGHTNING_ADDRESS ?? "",
|
depositLightningAddress: process.env.DEPOSIT_LIGHTNING_ADDRESS ?? "",
|
||||||
depositLnurlp: process.env.DEPOSIT_LNURLP ?? "",
|
depositLnurlp: process.env.DEPOSIT_LNURLP ?? "",
|
||||||
cashuRedeemApiUrl: (process.env.CASHU_REDEEM_API_URL ?? "https://cashu-redeem.azzamo.net").replace(/\/$/, ""),
|
cashuRedeemApiUrl: (process.env.CASHU_REDEEM_API_URL ?? "https://cashu-redeem.azzamo.net").replace(/\/$/, ""),
|
||||||
|
|
||||||
|
// Sponsors
|
||||||
|
baseSponsorPricePerDay: envInt("BASE_SPONSOR_PRICE_PER_DAY", 200),
|
||||||
|
sponsorMaxActivePerUser: envInt("SPONSOR_MAX_ACTIVE_PER_USER", 5),
|
||||||
|
sponsorMaxVisible: envInt("SPONSOR_MAX_VISIBLE", 6),
|
||||||
|
adminPubkeys: (process.env.ADMIN_PUBKEYS ?? "").split(",").map((s) => s.trim()).filter(Boolean),
|
||||||
|
/** Public API base URL for LNbits webhook (e.g. https://api.example.com). Required for sponsor payments. */
|
||||||
|
publicApiUrl: (process.env.PUBLIC_API_URL ?? process.env.FRONTEND_URL ?? "").replace(/\/$/, ""),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function usePostgres(): boolean {
|
export function usePostgres(): boolean {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import pg from "pg";
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import type { ClaimRow, Db, DepositSource, IpLimitRow, QuoteRow, UserRow } from "./types.js";
|
import type { ClaimRow, Db, DepositSource, IpLimitRow, QuoteRow, SponsorRow, UserRow } from "./types.js";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export function createPgDb(connectionString: string): Db {
|
|||||||
expires_at: Number(r.expires_at),
|
expires_at: Number(r.expires_at),
|
||||||
status: r.status,
|
status: r.status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async runMigrations() {
|
async runMigrations() {
|
||||||
@@ -84,6 +84,40 @@ export function createPgDb(connectionString: string): Db {
|
|||||||
"UPDATE deposits SET amount_sats = amount_sats / 1000 WHERE source = 'lightning' AND lnbits_payment_hash IS NOT NULL AND amount_sats >= 1000"
|
"UPDATE deposits SET amount_sats = amount_sats / 1000 WHERE source = 'lightning' AND lnbits_payment_hash IS NOT NULL AND amount_sats >= 1000"
|
||||||
);
|
);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`CREATE TABLE IF NOT EXISTS sponsors (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
npub TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
image_url TEXT,
|
||||||
|
link_url TEXT NOT NULL,
|
||||||
|
category TEXT,
|
||||||
|
lightning_address TEXT,
|
||||||
|
invoice_id TEXT,
|
||||||
|
payment_hash TEXT,
|
||||||
|
price_sats INTEGER NOT NULL,
|
||||||
|
duration_days INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('pending_payment','pending_review','active','expired','removed')),
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
activated_at BIGINT,
|
||||||
|
expires_at BIGINT,
|
||||||
|
views INTEGER DEFAULT 0,
|
||||||
|
clicks INTEGER DEFAULT 0
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
await pool.query("CREATE INDEX IF NOT EXISTS idx_sponsors_status ON sponsors(status)");
|
||||||
|
await pool.query("CREATE INDEX IF NOT EXISTS idx_sponsors_npub ON sponsors(npub)");
|
||||||
|
await pool.query("CREATE INDEX IF NOT EXISTS idx_sponsors_expires_at ON sponsors(expires_at)");
|
||||||
|
await pool.query("CREATE INDEX IF NOT EXISTS idx_sponsors_payment_hash ON sponsors(payment_hash)");
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await pool.query("ALTER TABLE sponsors ADD COLUMN extends_sponsor_id INTEGER");
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await pool.query("ALTER TABLE sponsors ADD COLUMN payment_request TEXT");
|
||||||
|
} catch (_) {}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getUser(pubkey: string): Promise<UserRow | null> {
|
async getUser(pubkey: string): Promise<UserRow | null> {
|
||||||
@@ -343,5 +377,212 @@ export function createPgDb(connectionString: string): Db {
|
|||||||
created_at: Number(r.created_at),
|
created_at: Number(r.created_at),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async insertSponsor(row: Omit<SponsorRow, "id" | "views" | "clicks"> & { extends_sponsor_id?: number | null }): Promise<number> {
|
||||||
|
const res = await pool.query(
|
||||||
|
`INSERT INTO sponsors (npub, title, description, image_url, link_url, category, lightning_address, invoice_id, payment_hash, payment_request, price_sats, duration_days, status, created_at, activated_at, expires_at, extends_sponsor_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING id`,
|
||||||
|
[
|
||||||
|
row.npub,
|
||||||
|
row.title,
|
||||||
|
row.description,
|
||||||
|
row.image_url ?? null,
|
||||||
|
row.link_url,
|
||||||
|
row.category ?? null,
|
||||||
|
row.lightning_address ?? null,
|
||||||
|
row.invoice_id ?? null,
|
||||||
|
row.payment_hash ?? null,
|
||||||
|
row.payment_request ?? null,
|
||||||
|
row.price_sats,
|
||||||
|
row.duration_days,
|
||||||
|
row.status,
|
||||||
|
row.created_at,
|
||||||
|
row.activated_at ?? null,
|
||||||
|
row.expires_at ?? null,
|
||||||
|
row.extends_sponsor_id ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return res.rows[0].id;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSponsorById(id: number): Promise<SponsorRow | null> {
|
||||||
|
const res = await pool.query("SELECT * FROM sponsors WHERE id = $1", [id]);
|
||||||
|
if (!res.rows.length) return null;
|
||||||
|
return toSponsorRow(res.rows[0]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSponsorByPaymentHash(paymentHash: string): Promise<SponsorRow | null> {
|
||||||
|
const res = await pool.query("SELECT * FROM sponsors WHERE payment_hash = $1", [paymentHash]);
|
||||||
|
if (!res.rows.length) return null;
|
||||||
|
return toSponsorRow(res.rows[0]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSponsorOnPayment(paymentHash: string, activatedAt: number, expiresAt: number): Promise<boolean> {
|
||||||
|
const res = await pool.query(
|
||||||
|
"UPDATE sponsors SET status = 'active', activated_at = $1, expires_at = $2 WHERE payment_hash = $3 AND status = 'pending_payment' AND extends_sponsor_id IS NULL RETURNING id",
|
||||||
|
[activatedAt, expiresAt, paymentHash]
|
||||||
|
);
|
||||||
|
return res.rowCount !== null && res.rowCount > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSponsorExpiresAtAdd(id: number, additionalSeconds: number): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE sponsors SET expires_at = CASE
|
||||||
|
WHEN expires_at IS NULL OR expires_at < $1 THEN $1 + $2
|
||||||
|
ELSE expires_at + $2
|
||||||
|
END WHERE id = $3`,
|
||||||
|
[now, additionalSeconds, id]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getActiveSponsorsForHomepage(limit: number): Promise<SponsorRow[]> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const res = await pool.query(
|
||||||
|
`SELECT * FROM sponsors WHERE status = 'active' AND expires_at > $1 ORDER BY
|
||||||
|
(expires_at - $2) * 2 + COALESCE(activated_at, 0)::float / 86400 + random() DESC LIMIT $3`,
|
||||||
|
[now, now, limit]
|
||||||
|
);
|
||||||
|
return res.rows.map(toSponsorRow);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSponsorsForPage(): Promise<SponsorRow[]> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const res = await pool.query(
|
||||||
|
"SELECT * FROM sponsors WHERE status = 'active' AND expires_at > $1 ORDER BY activated_at DESC",
|
||||||
|
[now]
|
||||||
|
);
|
||||||
|
return res.rows.map(toSponsorRow);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSponsorsByNpub(npub: string): Promise<SponsorRow[]> {
|
||||||
|
const res = await pool.query("SELECT * FROM sponsors WHERE npub = $1 ORDER BY created_at DESC", [npub]);
|
||||||
|
return res.rows.map(toSponsorRow);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllSponsors(opts: { status?: string; limit: number }): Promise<SponsorRow[]> {
|
||||||
|
const { status, limit } = opts;
|
||||||
|
let query = "SELECT * FROM sponsors WHERE 1=1";
|
||||||
|
const params: unknown[] = [];
|
||||||
|
let i = 1;
|
||||||
|
if (status) {
|
||||||
|
query += ` AND status = $${i++}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
query += ` ORDER BY created_at DESC LIMIT $${i}`;
|
||||||
|
params.push(limit);
|
||||||
|
const res = await pool.query(query, params);
|
||||||
|
return res.rows.map(toSponsorRow);
|
||||||
|
},
|
||||||
|
|
||||||
|
async incrementSponsorViews(id: number): Promise<void> {
|
||||||
|
await pool.query("UPDATE sponsors SET views = views + 1 WHERE id = $1", [id]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async incrementSponsorClicks(id: number): Promise<void> {
|
||||||
|
await pool.query("UPDATE sponsors SET clicks = clicks + 1 WHERE id = $1", [id]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRecentSponsorPayments(limit: number): Promise<{ created_at: number; amount_sats: number; title: string }[]> {
|
||||||
|
const res = await pool.query(
|
||||||
|
"SELECT activated_at as created_at, price_sats as amount_sats, title FROM sponsors WHERE status = 'active' AND activated_at IS NOT NULL ORDER BY activated_at DESC LIMIT $1",
|
||||||
|
[limit]
|
||||||
|
);
|
||||||
|
return res.rows.map((r) => ({
|
||||||
|
created_at: Number(r.created_at),
|
||||||
|
amount_sats: Number(r.amount_sats),
|
||||||
|
title: r.title,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
async countActiveSponsorsByNpub(npub: string): Promise<number> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const res = await pool.query(
|
||||||
|
"SELECT COUNT(*) as c FROM sponsors WHERE npub = $1 AND status = 'active' AND expires_at > $2",
|
||||||
|
[npub, now]
|
||||||
|
);
|
||||||
|
return parseInt(res.rows[0]?.c ?? "0", 10);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSponsorStatus(id: number, status: SponsorRow["status"]): Promise<void> {
|
||||||
|
await pool.query("UPDATE sponsors SET status = $1 WHERE id = $2", [status, id]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSponsorExpiresAt(id: number, expiresAt: number): Promise<void> {
|
||||||
|
await pool.query("UPDATE sponsors SET expires_at = $1 WHERE id = $2", [expiresAt, id]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSponsorActivation(id: number, activatedAt: number, expiresAt: number): Promise<void> {
|
||||||
|
await pool.query("UPDATE sponsors SET activated_at = $1, expires_at = $2 WHERE id = $3", [activatedAt, expiresAt, id]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSponsor(
|
||||||
|
id: number,
|
||||||
|
data: Partial<Pick<SponsorRow, "title" | "description" | "image_url" | "link_url" | "category" | "lightning_address">>
|
||||||
|
): Promise<void> {
|
||||||
|
const updates: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let i = 1;
|
||||||
|
if (data.title !== undefined) {
|
||||||
|
updates.push(`title = $${i++}`);
|
||||||
|
values.push(data.title);
|
||||||
|
}
|
||||||
|
if (data.description !== undefined) {
|
||||||
|
updates.push(`description = $${i++}`);
|
||||||
|
values.push(data.description);
|
||||||
|
}
|
||||||
|
if (data.image_url !== undefined) {
|
||||||
|
updates.push(`image_url = $${i++}`);
|
||||||
|
values.push(data.image_url);
|
||||||
|
}
|
||||||
|
if (data.link_url !== undefined) {
|
||||||
|
updates.push(`link_url = $${i++}`);
|
||||||
|
values.push(data.link_url);
|
||||||
|
}
|
||||||
|
if (data.category !== undefined) {
|
||||||
|
updates.push(`category = $${i++}`);
|
||||||
|
values.push(data.category);
|
||||||
|
}
|
||||||
|
if (data.lightning_address !== undefined) {
|
||||||
|
updates.push(`lightning_address = $${i++}`);
|
||||||
|
values.push(data.lightning_address);
|
||||||
|
}
|
||||||
|
if (updates.length === 0) return;
|
||||||
|
values.push(id);
|
||||||
|
await pool.query(`UPDATE sponsors SET ${updates.join(", ")} WHERE id = $${i}`, values);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSponsorPayment(id: number, paymentHash: string, paymentRequest: string): Promise<void> {
|
||||||
|
await pool.query("UPDATE sponsors SET payment_hash = $1, payment_request = $2 WHERE id = $3", [
|
||||||
|
paymentHash,
|
||||||
|
paymentRequest,
|
||||||
|
id,
|
||||||
|
]);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toSponsorRow(r: pg.QueryResultRow): SponsorRow {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
npub: r.npub,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description,
|
||||||
|
image_url: r.image_url ?? null,
|
||||||
|
link_url: r.link_url,
|
||||||
|
category: r.category ?? null,
|
||||||
|
lightning_address: r.lightning_address ?? null,
|
||||||
|
invoice_id: r.invoice_id ?? null,
|
||||||
|
payment_hash: r.payment_hash ?? null,
|
||||||
|
payment_request: r.payment_request ?? null,
|
||||||
|
price_sats: Number(r.price_sats),
|
||||||
|
duration_days: Number(r.duration_days),
|
||||||
|
status: r.status,
|
||||||
|
created_at: Number(r.created_at),
|
||||||
|
activated_at: r.activated_at != null ? Number(r.activated_at) : null,
|
||||||
|
expires_at: r.expires_at != null ? Number(r.expires_at) : null,
|
||||||
|
views: Number(r.views ?? 0),
|
||||||
|
clicks: Number(r.clicks ?? 0),
|
||||||
|
extends_sponsor_id: r.extends_sponsor_id != null ? Number(r.extends_sponsor_id) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,3 +68,29 @@ CREATE INDEX IF NOT EXISTS idx_quotes_expires_at ON quotes(expires_at);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status);
|
CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at);
|
CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_deposits_lnbits_payment_hash ON deposits(lnbits_payment_hash);
|
CREATE INDEX IF NOT EXISTS idx_deposits_lnbits_payment_hash ON deposits(lnbits_payment_hash);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sponsors (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
npub TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
image_url TEXT,
|
||||||
|
link_url TEXT NOT NULL,
|
||||||
|
category TEXT,
|
||||||
|
lightning_address TEXT,
|
||||||
|
invoice_id TEXT,
|
||||||
|
payment_hash TEXT,
|
||||||
|
price_sats INTEGER NOT NULL,
|
||||||
|
duration_days INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('pending_payment','pending_review','active','expired','removed')),
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
activated_at BIGINT,
|
||||||
|
expires_at BIGINT,
|
||||||
|
views INTEGER DEFAULT 0,
|
||||||
|
clicks INTEGER DEFAULT 0,
|
||||||
|
extends_sponsor_id INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sponsors_status ON sponsors(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sponsors_npub ON sponsors(npub);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sponsors_expires_at ON sponsors(expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sponsors_payment_hash ON sponsors(payment_hash);
|
||||||
|
|||||||
@@ -69,3 +69,29 @@ CREATE INDEX IF NOT EXISTS idx_quotes_expires_at ON quotes(expires_at);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status);
|
CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at);
|
CREATE INDEX IF NOT EXISTS idx_deposits_created_at ON deposits(created_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_deposits_lnbits_payment_hash ON deposits(lnbits_payment_hash);
|
CREATE INDEX IF NOT EXISTS idx_deposits_lnbits_payment_hash ON deposits(lnbits_payment_hash);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sponsors (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
npub TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
image_url TEXT,
|
||||||
|
link_url TEXT NOT NULL,
|
||||||
|
category TEXT,
|
||||||
|
lightning_address TEXT,
|
||||||
|
invoice_id TEXT,
|
||||||
|
payment_hash TEXT,
|
||||||
|
price_sats INTEGER NOT NULL,
|
||||||
|
duration_days INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('pending_payment','pending_review','active','expired','removed')),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
activated_at INTEGER,
|
||||||
|
expires_at INTEGER,
|
||||||
|
views INTEGER DEFAULT 0,
|
||||||
|
clicks INTEGER DEFAULT 0,
|
||||||
|
extends_sponsor_id INTEGER
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sponsors_status ON sponsors(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sponsors_npub ON sponsors(npub);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sponsors_expires_at ON sponsors(expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sponsors_payment_hash ON sponsors(payment_hash);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Database from "better-sqlite3";
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import type { ClaimRow, Db, DepositSource, IpLimitRow, QuoteRow, UserRow } from "./types.js";
|
import type { ClaimRow, Db, DepositSource, IpLimitRow, QuoteRow, SponsorRow, UserRow } from "./types.js";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -36,6 +36,40 @@ export function createSqliteDb(path: string): Db {
|
|||||||
"UPDATE deposits SET amount_sats = amount_sats / 1000 WHERE source = 'lightning' AND lnbits_payment_hash IS NOT NULL AND amount_sats >= 1000"
|
"UPDATE deposits SET amount_sats = amount_sats / 1000 WHERE source = 'lightning' AND lnbits_payment_hash IS NOT NULL AND amount_sats >= 1000"
|
||||||
);
|
);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
db.exec(
|
||||||
|
`CREATE TABLE IF NOT EXISTS sponsors (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
npub TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
image_url TEXT,
|
||||||
|
link_url TEXT NOT NULL,
|
||||||
|
category TEXT,
|
||||||
|
lightning_address TEXT,
|
||||||
|
invoice_id TEXT,
|
||||||
|
payment_hash TEXT,
|
||||||
|
price_sats INTEGER NOT NULL,
|
||||||
|
duration_days INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('pending_payment','pending_review','active','expired','removed')),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
activated_at INTEGER,
|
||||||
|
expires_at INTEGER,
|
||||||
|
views INTEGER DEFAULT 0,
|
||||||
|
clicks INTEGER DEFAULT 0
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
db.exec("CREATE INDEX IF NOT EXISTS idx_sponsors_status ON sponsors(status)");
|
||||||
|
db.exec("CREATE INDEX IF NOT EXISTS idx_sponsors_npub ON sponsors(npub)");
|
||||||
|
db.exec("CREATE INDEX IF NOT EXISTS idx_sponsors_expires_at ON sponsors(expires_at)");
|
||||||
|
db.exec("CREATE INDEX IF NOT EXISTS idx_sponsors_payment_hash ON sponsors(payment_hash)");
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
db.exec("ALTER TABLE sponsors ADD COLUMN extends_sponsor_id INTEGER");
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
db.exec("ALTER TABLE sponsors ADD COLUMN payment_request TEXT");
|
||||||
|
} catch (_) {}
|
||||||
},
|
},
|
||||||
|
|
||||||
async getUser(pubkey: string): Promise<UserRow | null> {
|
async getUser(pubkey: string): Promise<UserRow | null> {
|
||||||
@@ -283,5 +317,184 @@ export function createSqliteDb(path: string): Db {
|
|||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async insertSponsor(row: Omit<SponsorRow, "id" | "views" | "clicks"> & { extends_sponsor_id?: number | null }): Promise<number> {
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO sponsors (npub, title, description, image_url, link_url, category, lightning_address, invoice_id, payment_hash, payment_request, price_sats, duration_days, status, created_at, activated_at, expires_at, extends_sponsor_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
row.npub,
|
||||||
|
row.title,
|
||||||
|
row.description,
|
||||||
|
row.image_url ?? null,
|
||||||
|
row.link_url,
|
||||||
|
row.category ?? null,
|
||||||
|
row.lightning_address ?? null,
|
||||||
|
row.invoice_id ?? null,
|
||||||
|
row.payment_hash ?? null,
|
||||||
|
row.payment_request ?? null,
|
||||||
|
row.price_sats,
|
||||||
|
row.duration_days,
|
||||||
|
row.status,
|
||||||
|
row.created_at,
|
||||||
|
row.activated_at ?? null,
|
||||||
|
row.expires_at ?? null,
|
||||||
|
row.extends_sponsor_id ?? null
|
||||||
|
);
|
||||||
|
return result.lastInsertRowid as number;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSponsorById(id: number): Promise<SponsorRow | null> {
|
||||||
|
const row = db.prepare("SELECT * FROM sponsors WHERE id = ?").get(id) as SponsorRow | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
return { ...row, views: row.views ?? 0, clicks: row.clicks ?? 0 };
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSponsorByPaymentHash(paymentHash: string): Promise<SponsorRow | null> {
|
||||||
|
const row = db.prepare("SELECT * FROM sponsors WHERE payment_hash = ?").get(paymentHash) as SponsorRow | undefined;
|
||||||
|
if (!row) return null;
|
||||||
|
return { ...row, views: row.views ?? 0, clicks: row.clicks ?? 0 };
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSponsorOnPayment(paymentHash: string, activatedAt: number, expiresAt: number): Promise<boolean> {
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
"UPDATE sponsors SET status = 'active', activated_at = ?, expires_at = ? WHERE payment_hash = ? AND status = 'pending_payment' AND extends_sponsor_id IS NULL"
|
||||||
|
)
|
||||||
|
.run(activatedAt, expiresAt, paymentHash);
|
||||||
|
return result.changes > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSponsorExpiresAtAdd(id: number, additionalSeconds: number): Promise<void> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE sponsors SET expires_at = CASE
|
||||||
|
WHEN expires_at IS NULL OR expires_at < ? THEN ? + ?
|
||||||
|
ELSE expires_at + ?
|
||||||
|
END WHERE id = ?`
|
||||||
|
).run(now, now, additionalSeconds, additionalSeconds, id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getActiveSponsorsForHomepage(limit: number): Promise<SponsorRow[]> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM sponsors WHERE status = 'active' AND expires_at > ? ORDER BY
|
||||||
|
(expires_at - ?) * 2 + COALESCE(activated_at, 0) / 86400 DESC, random() LIMIT ?`
|
||||||
|
)
|
||||||
|
.all(now, now, limit) as SponsorRow[];
|
||||||
|
return rows.map((r) => ({ ...r, views: r.views ?? 0, clicks: r.clicks ?? 0, extends_sponsor_id: r.extends_sponsor_id ?? null }));
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSponsorsForPage(): Promise<SponsorRow[]> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const rows = db
|
||||||
|
.prepare("SELECT * FROM sponsors WHERE status = 'active' AND expires_at > ? ORDER BY activated_at DESC")
|
||||||
|
.all(now) as SponsorRow[];
|
||||||
|
return rows.map((r) => ({ ...r, views: r.views ?? 0, clicks: r.clicks ?? 0, extends_sponsor_id: r.extends_sponsor_id ?? null }));
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSponsorsByNpub(npub: string): Promise<SponsorRow[]> {
|
||||||
|
const rows = db.prepare("SELECT * FROM sponsors WHERE npub = ? ORDER BY created_at DESC").all(npub) as SponsorRow[];
|
||||||
|
return rows.map((r) => ({ ...r, views: r.views ?? 0, clicks: r.clicks ?? 0, extends_sponsor_id: r.extends_sponsor_id ?? null }));
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllSponsors(opts: { status?: string; limit: number }): Promise<SponsorRow[]> {
|
||||||
|
const { status, limit } = opts;
|
||||||
|
let query = "SELECT * FROM sponsors WHERE 1=1";
|
||||||
|
const params: unknown[] = [];
|
||||||
|
if (status) {
|
||||||
|
query += " AND status = ?";
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
query += " ORDER BY created_at DESC LIMIT ?";
|
||||||
|
params.push(limit);
|
||||||
|
const rows = db.prepare(query).all(...params) as SponsorRow[];
|
||||||
|
return rows.map((r) => ({ ...r, views: r.views ?? 0, clicks: r.clicks ?? 0, extends_sponsor_id: r.extends_sponsor_id ?? null }));
|
||||||
|
},
|
||||||
|
|
||||||
|
async incrementSponsorViews(id: number): Promise<void> {
|
||||||
|
db.prepare("UPDATE sponsors SET views = views + 1 WHERE id = ?").run(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async incrementSponsorClicks(id: number): Promise<void> {
|
||||||
|
db.prepare("UPDATE sponsors SET clicks = clicks + 1 WHERE id = ?").run(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRecentSponsorPayments(limit: number): Promise<{ created_at: number; amount_sats: number; title: string }[]> {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT activated_at as created_at, price_sats as amount_sats, title FROM sponsors WHERE status = 'active' AND activated_at IS NOT NULL ORDER BY activated_at DESC LIMIT ?"
|
||||||
|
)
|
||||||
|
.all(limit) as { created_at: number; amount_sats: number; title: string }[];
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
async countActiveSponsorsByNpub(npub: string): Promise<number> {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT COUNT(*) as c FROM sponsors WHERE npub = ? AND status = 'active' AND expires_at > ?"
|
||||||
|
)
|
||||||
|
.get(npub, now) as { c: number };
|
||||||
|
return row?.c ?? 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSponsorStatus(id: number, status: SponsorRow["status"]): Promise<void> {
|
||||||
|
db.prepare("UPDATE sponsors SET status = ? WHERE id = ?").run(status, id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSponsorExpiresAt(id: number, expiresAt: number): Promise<void> {
|
||||||
|
db.prepare("UPDATE sponsors SET expires_at = ? WHERE id = ?").run(expiresAt, id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSponsorActivation(id: number, activatedAt: number, expiresAt: number): Promise<void> {
|
||||||
|
db.prepare("UPDATE sponsors SET activated_at = ?, expires_at = ? WHERE id = ?").run(activatedAt, expiresAt, id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSponsor(
|
||||||
|
id: number,
|
||||||
|
data: Partial<Pick<SponsorRow, "title" | "description" | "image_url" | "link_url" | "category" | "lightning_address">>
|
||||||
|
): Promise<void> {
|
||||||
|
const updates: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
if (data.title !== undefined) {
|
||||||
|
updates.push("title = ?");
|
||||||
|
values.push(data.title);
|
||||||
|
}
|
||||||
|
if (data.description !== undefined) {
|
||||||
|
updates.push("description = ?");
|
||||||
|
values.push(data.description);
|
||||||
|
}
|
||||||
|
if (data.image_url !== undefined) {
|
||||||
|
updates.push("image_url = ?");
|
||||||
|
values.push(data.image_url);
|
||||||
|
}
|
||||||
|
if (data.link_url !== undefined) {
|
||||||
|
updates.push("link_url = ?");
|
||||||
|
values.push(data.link_url);
|
||||||
|
}
|
||||||
|
if (data.category !== undefined) {
|
||||||
|
updates.push("category = ?");
|
||||||
|
values.push(data.category);
|
||||||
|
}
|
||||||
|
if (data.lightning_address !== undefined) {
|
||||||
|
updates.push("lightning_address = ?");
|
||||||
|
values.push(data.lightning_address);
|
||||||
|
}
|
||||||
|
if (updates.length === 0) return;
|
||||||
|
values.push(id);
|
||||||
|
db.prepare(`UPDATE sponsors SET ${updates.join(", ")} WHERE id = ?`).run(...values);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSponsorPayment(id: number, paymentHash: string, paymentRequest: string): Promise<void> {
|
||||||
|
db.prepare("UPDATE sponsors SET payment_hash = ?, payment_request = ? WHERE id = ?").run(
|
||||||
|
paymentHash,
|
||||||
|
paymentRequest,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,31 @@ export interface IpLimitRow {
|
|||||||
|
|
||||||
export type DepositSource = "lightning" | "cashu";
|
export type DepositSource = "lightning" | "cashu";
|
||||||
|
|
||||||
|
export type SponsorStatus = "pending_payment" | "pending_review" | "active" | "expired" | "removed";
|
||||||
|
|
||||||
|
export interface SponsorRow {
|
||||||
|
id: number;
|
||||||
|
npub: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
image_url: string | null;
|
||||||
|
link_url: string;
|
||||||
|
category: string | null;
|
||||||
|
lightning_address: string | null;
|
||||||
|
invoice_id: string | null;
|
||||||
|
payment_hash: string | null;
|
||||||
|
payment_request: string | null;
|
||||||
|
price_sats: number;
|
||||||
|
duration_days: number;
|
||||||
|
status: SponsorStatus;
|
||||||
|
created_at: number;
|
||||||
|
activated_at: number | null;
|
||||||
|
expires_at: number | null;
|
||||||
|
views: number;
|
||||||
|
clicks: number;
|
||||||
|
extends_sponsor_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DepositRow {
|
export interface DepositRow {
|
||||||
id: number;
|
id: number;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
@@ -98,4 +123,23 @@ export interface Db {
|
|||||||
hasDepositWithPaymentHash(paymentHash: string): Promise<boolean>;
|
hasDepositWithPaymentHash(paymentHash: string): Promise<boolean>;
|
||||||
updateDepositCreatedAtIfMissing(paymentHash: string, createdAt: number): Promise<boolean>;
|
updateDepositCreatedAtIfMissing(paymentHash: string, createdAt: number): Promise<boolean>;
|
||||||
getRecentDeposits(limit: number): Promise<{ amount_sats: number; source: DepositSource; created_at: number }[]>;
|
getRecentDeposits(limit: number): Promise<{ amount_sats: number; source: DepositSource; created_at: number }[]>;
|
||||||
|
|
||||||
|
insertSponsor(row: Omit<SponsorRow, "id" | "views" | "clicks" | "extends_sponsor_id"> & { extends_sponsor_id?: number | null }): Promise<number>;
|
||||||
|
getSponsorById(id: number): Promise<SponsorRow | null>;
|
||||||
|
getSponsorByPaymentHash(paymentHash: string): Promise<SponsorRow | null>;
|
||||||
|
updateSponsorOnPayment(paymentHash: string, activatedAt: number, expiresAt: number): Promise<boolean>;
|
||||||
|
updateSponsorExpiresAtAdd(id: number, additionalSeconds: number): Promise<void>;
|
||||||
|
getActiveSponsorsForHomepage(limit: number): Promise<SponsorRow[]>;
|
||||||
|
getSponsorsForPage(): Promise<SponsorRow[]>;
|
||||||
|
getSponsorsByNpub(npub: string): Promise<SponsorRow[]>;
|
||||||
|
getAllSponsors(opts: { status?: string; limit: number }): Promise<SponsorRow[]>;
|
||||||
|
incrementSponsorViews(id: number): Promise<void>;
|
||||||
|
incrementSponsorClicks(id: number): Promise<void>;
|
||||||
|
getRecentSponsorPayments(limit: number): Promise<{ created_at: number; amount_sats: number; title: string }[]>;
|
||||||
|
countActiveSponsorsByNpub(npub: string): Promise<number>;
|
||||||
|
updateSponsorStatus(id: number, status: SponsorStatus): Promise<void>;
|
||||||
|
updateSponsorExpiresAt(id: number, expiresAt: number): Promise<void>;
|
||||||
|
updateSponsorActivation(id: number, activatedAt: number, expiresAt: number): Promise<void>;
|
||||||
|
updateSponsor(id: number, data: Partial<Pick<SponsorRow, "title" | "description" | "image_url" | "link_url" | "category" | "lightning_address">>): Promise<void>;
|
||||||
|
updateSponsorPayment(id: number, paymentHash: string, paymentRequest: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import publicRoutes from "./routes/public.js";
|
|||||||
import authRoutes from "./routes/auth.js";
|
import authRoutes from "./routes/auth.js";
|
||||||
import claimRoutes from "./routes/claim.js";
|
import claimRoutes from "./routes/claim.js";
|
||||||
import userRoutes from "./routes/user.js";
|
import userRoutes from "./routes/user.js";
|
||||||
|
import sponsorWebhookRoutes from "./routes/sponsorWebhook.js";
|
||||||
|
import sponsorRoutes from "./routes/sponsor.js";
|
||||||
|
import adminRoutes from "./routes/admin.js";
|
||||||
|
|
||||||
const NONCE_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
const NONCE_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
@@ -62,6 +65,9 @@ async function main() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
app.use("/", publicRoutes);
|
app.use("/", publicRoutes);
|
||||||
|
app.use("/", sponsorWebhookRoutes);
|
||||||
|
app.use("/sponsor", sponsorRoutes);
|
||||||
|
app.use("/admin", adminRoutes);
|
||||||
app.use("/auth", authRoutes);
|
app.use("/auth", authRoutes);
|
||||||
app.use(
|
app.use(
|
||||||
"/claim",
|
"/claim",
|
||||||
|
|||||||
118
backend/src/routes/admin.ts
Normal file
118
backend/src/routes/admin.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from "express";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { getDb } from "../db/index.js";
|
||||||
|
import { verifyJwt } from "../auth/jwt.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
function adminAuth(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
if (config.adminPubkeys.length === 0) {
|
||||||
|
res.status(503).json({ code: "admin_disabled", message: "Admin API not configured" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
const adminKey = req.headers["x-admin-key"];
|
||||||
|
let pubkey: string | null = null;
|
||||||
|
|
||||||
|
if (auth?.startsWith("Bearer ")) {
|
||||||
|
const token = auth.slice(7).trim();
|
||||||
|
const payload = verifyJwt(token);
|
||||||
|
if (payload?.pubkey) pubkey = payload.pubkey;
|
||||||
|
}
|
||||||
|
if (!pubkey && typeof adminKey === "string") {
|
||||||
|
pubkey = adminKey.trim();
|
||||||
|
}
|
||||||
|
if (!pubkey || !config.adminPubkeys.includes(pubkey)) {
|
||||||
|
res.status(403).json({ code: "forbidden", message: "Admin access required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
router.use(adminAuth);
|
||||||
|
|
||||||
|
router.get("/sponsors", async (req: Request, res: Response) => {
|
||||||
|
const db = getDb();
|
||||||
|
const status = typeof req.query.status === "string" ? req.query.status : undefined;
|
||||||
|
const limit = Math.min(parseInt(String(req.query.limit || 100), 10) || 100, 500);
|
||||||
|
|
||||||
|
const sponsors = await db.getAllSponsors({ status, limit });
|
||||||
|
|
||||||
|
res.json(sponsors);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/sponsors/:id", async (req: Request, res: Response) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isFinite(id) || id < 1) {
|
||||||
|
res.status(400).json({ code: "invalid_id", message: "Invalid sponsor id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = typeof (req.body as { action?: string }).action === "string"
|
||||||
|
? (req.body as { action: string }).action
|
||||||
|
: "";
|
||||||
|
const durationDays = typeof (req.body as { duration_days?: number }).duration_days === "number"
|
||||||
|
? (req.body as { duration_days: number }).duration_days
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const sponsor = await db.getSponsorById(id);
|
||||||
|
if (!sponsor) {
|
||||||
|
res.status(404).json({ code: "not_found", message: "Sponsor not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "approve":
|
||||||
|
await db.updateSponsorStatus(id, "active");
|
||||||
|
if (!sponsor.activated_at || !sponsor.expires_at) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const expiresAt = now + sponsor.duration_days * 86400;
|
||||||
|
await db.updateSponsorActivation(id, now, expiresAt);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "reject":
|
||||||
|
await db.updateSponsorStatus(id, "removed");
|
||||||
|
break;
|
||||||
|
case "pause":
|
||||||
|
await db.updateSponsorStatus(id, "pending_review");
|
||||||
|
break;
|
||||||
|
case "remove":
|
||||||
|
await db.updateSponsorStatus(id, "removed");
|
||||||
|
break;
|
||||||
|
case "extend":
|
||||||
|
if (durationDays && durationDays >= 1 && durationDays <= 365) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const current = sponsor.expires_at ?? now;
|
||||||
|
const newExpires = Math.max(current, now) + durationDays * 86400;
|
||||||
|
await db.updateSponsorExpiresAt(id, newExpires);
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ code: "invalid_duration", message: "duration_days 1-365 required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
res.status(400).json({ code: "invalid_action", message: "action must be approve, reject, pause, remove, or extend" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await db.getSponsorById(id);
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/sponsors/:id", async (req: Request, res: Response) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isFinite(id) || id < 1) {
|
||||||
|
res.status(400).json({ code: "invalid_id", message: "Invalid sponsor id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
const sponsor = await db.getSponsorById(id);
|
||||||
|
if (!sponsor) {
|
||||||
|
res.status(404).json({ code: "not_found", message: "Sponsor not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await db.updateSponsorStatus(id, "removed");
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -26,7 +26,7 @@ router.get("/stats", async (_req: Request, res: Response) => {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const dayStart = now - (now % 86400);
|
const dayStart = now - (now % 86400);
|
||||||
const [balance, totalPaid, totalClaims, claims24h, spentToday, recent, recentDeposits] = await Promise.all([
|
const [balance, totalPaid, totalClaims, claims24h, spentToday, recent, recentDeposits, recentSponsorPayments] = await Promise.all([
|
||||||
getWalletBalanceSats().catch(() => 0),
|
getWalletBalanceSats().catch(() => 0),
|
||||||
db.getTotalPaidSats(),
|
db.getTotalPaidSats(),
|
||||||
db.getTotalClaimsCount(),
|
db.getTotalClaimsCount(),
|
||||||
@@ -34,6 +34,7 @@ router.get("/stats", async (_req: Request, res: Response) => {
|
|||||||
db.getPaidSatsSince(dayStart),
|
db.getPaidSatsSince(dayStart),
|
||||||
db.getRecentPayouts(20),
|
db.getRecentPayouts(20),
|
||||||
db.getRecentDeposits(20),
|
db.getRecentDeposits(20),
|
||||||
|
db.getRecentSponsorPayments(20),
|
||||||
]);
|
]);
|
||||||
res.json({
|
res.json({
|
||||||
balanceSats: balance,
|
balanceSats: balance,
|
||||||
@@ -44,6 +45,7 @@ router.get("/stats", async (_req: Request, res: Response) => {
|
|||||||
spentTodaySats: spentToday,
|
spentTodaySats: spentToday,
|
||||||
recentPayouts: recent,
|
recentPayouts: recent,
|
||||||
recentDeposits,
|
recentDeposits,
|
||||||
|
recentSponsorPayments,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
|
|||||||
454
backend/src/routes/sponsor.ts
Normal file
454
backend/src/routes/sponsor.ts
Normal file
@@ -0,0 +1,454 @@
|
|||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { getDb } from "../db/index.js";
|
||||||
|
import { createInvoice, checkPaymentStatus } from "../services/lnbits.js";
|
||||||
|
import { fetchOgMeta } from "../services/fetchOgMeta.js";
|
||||||
|
import { authOrNip98 } from "../middleware/auth.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const SNAP_DAYS = [1, 3, 7, 14, 30, 60, 90, 180, 365];
|
||||||
|
|
||||||
|
function snapDays(days: number): number {
|
||||||
|
let best = SNAP_DAYS[0];
|
||||||
|
for (const d of SNAP_DAYS) {
|
||||||
|
if (Math.abs(d - days) < Math.abs(best - days)) best = d;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePrice(days: number): number {
|
||||||
|
const base = config.baseSponsorPricePerDay;
|
||||||
|
let price = base * days;
|
||||||
|
if (days >= 180) price *= 0.7;
|
||||||
|
else if (days >= 90) price *= 0.8;
|
||||||
|
else if (days >= 30) price *= 0.9;
|
||||||
|
return Math.round(price);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireAuth(req: Request): string | null {
|
||||||
|
if (!req.nostr?.pubkey) return null;
|
||||||
|
return req.nostr.pubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateSponsorBody {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
link_url?: string;
|
||||||
|
image_url?: string;
|
||||||
|
category?: string;
|
||||||
|
lightning_address?: string;
|
||||||
|
duration_days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post("/create", authOrNip98, async (req: Request, res: Response) => {
|
||||||
|
const pubkey = requireAuth(req);
|
||||||
|
if (!pubkey) {
|
||||||
|
res.status(401).json({ code: "unauthorized", message: "Login required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (req.body || {}) as CreateSponsorBody;
|
||||||
|
const title = typeof body.title === "string" ? body.title.trim() : "";
|
||||||
|
const description = typeof body.description === "string" ? body.description.trim() : "";
|
||||||
|
const linkUrl = typeof body.link_url === "string" ? body.link_url.trim() : "";
|
||||||
|
const imageUrl = typeof body.image_url === "string" ? body.image_url.trim() || null : null;
|
||||||
|
const category = typeof body.category === "string" ? body.category.trim() || null : null;
|
||||||
|
const lightningAddress = typeof body.lightning_address === "string" ? body.lightning_address.trim() || null : null;
|
||||||
|
let durationDays = typeof body.duration_days === "number" ? body.duration_days : 0;
|
||||||
|
|
||||||
|
if (!title || title.length > 100) {
|
||||||
|
res.status(400).json({ code: "invalid_title", message: "Title is required (max 100 chars)" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!description || description.length > 500) {
|
||||||
|
res.status(400).json({ code: "invalid_description", message: "Description is required (max 500 chars)" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!linkUrl || !/^https?:\/\/.+/.test(linkUrl)) {
|
||||||
|
res.status(400).json({ code: "invalid_link_url", message: "Valid link_url (https://...) is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
durationDays = snapDays(durationDays);
|
||||||
|
if (durationDays < 1 || durationDays > 365) {
|
||||||
|
res.status(400).json({ code: "invalid_duration", message: "Duration must be 1-365 days" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const activeCount = await db.countActiveSponsorsByNpub(pubkey);
|
||||||
|
if (activeCount >= config.sponsorMaxActivePerUser) {
|
||||||
|
res.status(403).json({
|
||||||
|
code: "max_sponsors",
|
||||||
|
message: `Maximum ${config.sponsorMaxActivePerUser} active sponsors per user`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceSats = calculatePrice(durationDays);
|
||||||
|
|
||||||
|
let finalImageUrl = imageUrl;
|
||||||
|
if (!finalImageUrl && linkUrl) {
|
||||||
|
try {
|
||||||
|
const og = await fetchOgMeta(linkUrl);
|
||||||
|
if (og.image) finalImageUrl = og.image;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookUrl = config.publicApiUrl
|
||||||
|
? `${config.publicApiUrl}/sponsor/webhook`
|
||||||
|
: "";
|
||||||
|
if (!webhookUrl) {
|
||||||
|
console.warn("[sponsor] PUBLIC_API_URL not set; webhook will not be called");
|
||||||
|
}
|
||||||
|
|
||||||
|
let paymentHash: string;
|
||||||
|
let paymentRequest: string;
|
||||||
|
try {
|
||||||
|
const inv = await createInvoice(
|
||||||
|
priceSats,
|
||||||
|
`Sponsor: ${title}`,
|
||||||
|
webhookUrl
|
||||||
|
);
|
||||||
|
paymentHash = inv.payment_hash;
|
||||||
|
paymentRequest = inv.payment_request;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[sponsor] createInvoice failed:", e);
|
||||||
|
res.status(502).json({ code: "invoice_failed", message: "Failed to create invoice" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const id = await db.insertSponsor({
|
||||||
|
npub: pubkey,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image_url: finalImageUrl,
|
||||||
|
link_url: linkUrl,
|
||||||
|
category,
|
||||||
|
lightning_address: lightningAddress,
|
||||||
|
invoice_id: null,
|
||||||
|
payment_hash: paymentHash,
|
||||||
|
payment_request: paymentRequest,
|
||||||
|
price_sats: priceSats,
|
||||||
|
duration_days: durationDays,
|
||||||
|
status: "pending_payment",
|
||||||
|
created_at: now,
|
||||||
|
activated_at: null,
|
||||||
|
expires_at: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
id,
|
||||||
|
payment_hash: paymentHash,
|
||||||
|
payment_request: paymentRequest,
|
||||||
|
price_sats: priceSats,
|
||||||
|
duration_days: durationDays,
|
||||||
|
status: "pending_payment",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/check-payment/:payment_hash", async (req: Request, res: Response) => {
|
||||||
|
const paymentHash = (req.params.payment_hash || "").trim();
|
||||||
|
if (!paymentHash) {
|
||||||
|
res.status(400).json({ paid: false, error: "Missing payment_hash" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { paid } = await checkPaymentStatus(paymentHash);
|
||||||
|
if (!paid) {
|
||||||
|
res.json({ paid: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
const sponsor = await db.getSponsorByPaymentHash(paymentHash);
|
||||||
|
if (!sponsor || sponsor.status !== "pending_payment") {
|
||||||
|
res.json({ paid: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (sponsor.extends_sponsor_id) {
|
||||||
|
const additionalSeconds = sponsor.duration_days * 86400;
|
||||||
|
await db.updateSponsorExpiresAtAdd(sponsor.extends_sponsor_id, additionalSeconds);
|
||||||
|
await db.updateSponsorStatus(sponsor.id, "removed");
|
||||||
|
} else {
|
||||||
|
const expiresAt = now + sponsor.duration_days * 86400;
|
||||||
|
await db.updateSponsorOnPayment(paymentHash, now, expiresAt);
|
||||||
|
}
|
||||||
|
res.json({ paid: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[sponsor] check-payment failed:", e);
|
||||||
|
res.status(502).json({ paid: false, error: "Failed to check payment" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/homepage", async (_req: Request, res: Response) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sponsors = await db.getActiveSponsorsForHomepage(config.sponsorMaxVisible);
|
||||||
|
res.json(sponsors.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
title: s.title,
|
||||||
|
description: s.description,
|
||||||
|
image_url: s.image_url,
|
||||||
|
link_url: s.link_url,
|
||||||
|
category: s.category,
|
||||||
|
expires_at: s.expires_at,
|
||||||
|
views: s.views,
|
||||||
|
clicks: s.clicks,
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/list", async (_req: Request, res: Response) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sponsors = await db.getSponsorsForPage();
|
||||||
|
res.json(sponsors.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
title: s.title,
|
||||||
|
description: s.description,
|
||||||
|
image_url: s.image_url,
|
||||||
|
link_url: s.link_url,
|
||||||
|
category: s.category,
|
||||||
|
expires_at: s.expires_at,
|
||||||
|
views: s.views,
|
||||||
|
clicks: s.clicks,
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/my-ads", authOrNip98, async (req: Request, res: Response) => {
|
||||||
|
const pubkey = requireAuth(req);
|
||||||
|
if (!pubkey) {
|
||||||
|
res.status(401).json({ code: "unauthorized", message: "Login required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
const sponsors = await db.getSponsorsByNpub(pubkey);
|
||||||
|
res.json(
|
||||||
|
sponsors.map((s) => ({
|
||||||
|
...s,
|
||||||
|
payment_request: s.status === "pending_payment" ? s.payment_request ?? null : undefined,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/:id/regenerate-invoice", authOrNip98, async (req: Request, res: Response) => {
|
||||||
|
const pubkey = requireAuth(req);
|
||||||
|
if (!pubkey) {
|
||||||
|
res.status(401).json({ code: "unauthorized", message: "Login required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isFinite(id) || id < 1) {
|
||||||
|
res.status(400).json({ code: "invalid_id", message: "Invalid sponsor id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
const sponsor = await db.getSponsorById(id);
|
||||||
|
if (!sponsor || sponsor.npub !== pubkey) {
|
||||||
|
res.status(404).json({ code: "not_found", message: "Sponsor not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sponsor.status !== "pending_payment") {
|
||||||
|
res.status(400).json({ code: "invalid_status", message: "Can only regenerate invoice for pending payment" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const priceSats = sponsor.price_sats;
|
||||||
|
const webhookUrl = config.publicApiUrl ? `${config.publicApiUrl}/sponsor/webhook` : "";
|
||||||
|
let paymentHash: string;
|
||||||
|
let paymentRequest: string;
|
||||||
|
try {
|
||||||
|
const inv = await createInvoice(priceSats, `Sponsor: ${sponsor.title}`, webhookUrl);
|
||||||
|
paymentHash = inv.payment_hash;
|
||||||
|
paymentRequest = inv.payment_request;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[sponsor] regenerate-invoice createInvoice failed:", e);
|
||||||
|
res.status(502).json({ code: "invoice_failed", message: "Failed to create invoice" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await db.updateSponsorPayment(id, paymentHash, paymentRequest);
|
||||||
|
res.json({
|
||||||
|
payment_hash: paymentHash,
|
||||||
|
payment_request: paymentRequest,
|
||||||
|
price_sats: priceSats,
|
||||||
|
duration_days: sponsor.duration_days,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/click/:id", async (req: Request, res: Response) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isFinite(id) || id < 1) {
|
||||||
|
res.status(400).json({ error: "Invalid sponsor id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
const sponsor = await db.getSponsorById(id);
|
||||||
|
if (!sponsor || sponsor.status !== "active") {
|
||||||
|
res.status(404).json({ error: "Sponsor not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await db.incrementSponsorClicks(id);
|
||||||
|
res.redirect(302, sponsor.link_url);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/:id/view", async (req: Request, res: Response) => {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isFinite(id) || id < 1) {
|
||||||
|
res.status(400).json({ error: "Invalid sponsor id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const db = getDb();
|
||||||
|
const sponsor = await db.getSponsorById(id);
|
||||||
|
if (!sponsor || sponsor.status !== "active") {
|
||||||
|
res.status(404).json({ error: "Sponsor not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await db.incrementSponsorViews(id);
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/:id/extend", authOrNip98, async (req: Request, res: Response) => {
|
||||||
|
const pubkey = requireAuth(req);
|
||||||
|
if (!pubkey) {
|
||||||
|
res.status(401).json({ code: "unauthorized", message: "Login required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sponsorId = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isFinite(sponsorId) || sponsorId < 1) {
|
||||||
|
res.status(400).json({ code: "invalid_id", message: "Invalid sponsor id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = req.body as { duration_days?: number } | undefined;
|
||||||
|
let durationDays = typeof body?.duration_days === "number" ? body.duration_days : 0;
|
||||||
|
durationDays = snapDays(durationDays);
|
||||||
|
if (durationDays < 1 || durationDays > 365) {
|
||||||
|
res.status(400).json({ code: "invalid_duration", message: "Duration must be 1-365 days" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const sponsor = await db.getSponsorById(sponsorId);
|
||||||
|
if (!sponsor || sponsor.npub !== pubkey) {
|
||||||
|
res.status(404).json({ code: "not_found", message: "Sponsor not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sponsor.status !== "active" && sponsor.status !== "expired") {
|
||||||
|
res.status(400).json({ code: "invalid_status", message: "Can only extend active or expired sponsors" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceSats = calculatePrice(durationDays);
|
||||||
|
const webhookUrl = config.publicApiUrl ? `${config.publicApiUrl}/sponsor/webhook` : "";
|
||||||
|
|
||||||
|
let paymentHash: string;
|
||||||
|
let paymentRequest: string;
|
||||||
|
try {
|
||||||
|
const inv = await createInvoice(priceSats, `Extend sponsor: ${sponsor.title}`, webhookUrl);
|
||||||
|
paymentHash = inv.payment_hash;
|
||||||
|
paymentRequest = inv.payment_request;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[sponsor] extend createInvoice failed:", e);
|
||||||
|
res.status(502).json({ code: "invoice_failed", message: "Failed to create invoice" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const currentExpires = sponsor.expires_at ?? now;
|
||||||
|
const newExpiresAt = Math.max(currentExpires, now) + durationDays * 86400;
|
||||||
|
|
||||||
|
await db.insertSponsor({
|
||||||
|
npub: pubkey,
|
||||||
|
title: sponsor.title,
|
||||||
|
description: sponsor.description,
|
||||||
|
image_url: sponsor.image_url,
|
||||||
|
link_url: sponsor.link_url,
|
||||||
|
category: sponsor.category,
|
||||||
|
lightning_address: sponsor.lightning_address,
|
||||||
|
invoice_id: null,
|
||||||
|
payment_hash: paymentHash,
|
||||||
|
payment_request: paymentRequest,
|
||||||
|
price_sats: priceSats,
|
||||||
|
duration_days: durationDays,
|
||||||
|
status: "pending_payment",
|
||||||
|
created_at: now,
|
||||||
|
activated_at: null,
|
||||||
|
expires_at: null,
|
||||||
|
extends_sponsor_id: sponsorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
sponsor_id: sponsorId,
|
||||||
|
payment_hash: paymentHash,
|
||||||
|
payment_request: paymentRequest,
|
||||||
|
price_sats: priceSats,
|
||||||
|
duration_days: durationDays,
|
||||||
|
new_expires_at: newExpiresAt,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/:id", authOrNip98, async (req: Request, res: Response) => {
|
||||||
|
const pubkey = requireAuth(req);
|
||||||
|
if (!pubkey) {
|
||||||
|
res.status(401).json({ code: "unauthorized", message: "Login required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isFinite(id) || id < 1) {
|
||||||
|
res.status(400).json({ code: "invalid_id", message: "Invalid sponsor id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const sponsor = await db.getSponsorById(id);
|
||||||
|
if (!sponsor || sponsor.npub !== pubkey) {
|
||||||
|
res.status(404).json({ code: "not_found", message: "Sponsor not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sponsor.status !== "pending_payment" && sponsor.status !== "active") {
|
||||||
|
res.status(400).json({ code: "invalid_status", message: "Can only edit pending or active sponsors" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = req.body as CreateSponsorBody;
|
||||||
|
const updates: Partial<Pick<import("../db/types.js").SponsorRow, "title" | "description" | "image_url" | "link_url" | "category" | "lightning_address">> = {};
|
||||||
|
if (typeof body.title === "string" && body.title.trim()) updates.title = body.title.trim();
|
||||||
|
if (typeof body.description === "string") updates.description = body.description.trim();
|
||||||
|
if (typeof body.link_url === "string" && /^https?:\/\/.+/.test(body.link_url)) updates.link_url = body.link_url.trim();
|
||||||
|
if (typeof body.image_url === "string") updates.image_url = body.image_url.trim() || null;
|
||||||
|
if (typeof body.category === "string") updates.category = body.category.trim() || null;
|
||||||
|
if (typeof body.lightning_address === "string") updates.lightning_address = body.lightning_address.trim() || null;
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await db.updateSponsor(id, updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await db.getSponsorById(id);
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/:id", authOrNip98, async (req: Request, res: Response) => {
|
||||||
|
const pubkey = requireAuth(req);
|
||||||
|
if (!pubkey) {
|
||||||
|
res.status(401).json({ code: "unauthorized", message: "Login required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!Number.isFinite(id) || id < 1) {
|
||||||
|
res.status(400).json({ code: "invalid_id", message: "Invalid sponsor id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const sponsor = await db.getSponsorById(id);
|
||||||
|
if (!sponsor || sponsor.npub !== pubkey) {
|
||||||
|
res.status(404).json({ code: "not_found", message: "Sponsor not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.updateSponsorStatus(id, "removed");
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
57
backend/src/routes/sponsorWebhook.ts
Normal file
57
backend/src/routes/sponsorWebhook.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { getDb } from "../db/index.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/** LNbits webhook payload when invoice is paid. */
|
||||||
|
interface LnbitsWebhookPayload {
|
||||||
|
payment_hash?: string;
|
||||||
|
payment_request?: string;
|
||||||
|
amount?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /sponsor/webhook
|
||||||
|
* Called by LNbits when a sponsor invoice is paid.
|
||||||
|
* No auth - webhook is invoked by LNbits server.
|
||||||
|
*/
|
||||||
|
router.post("/sponsor/webhook", async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const body = (req.body || {}) as LnbitsWebhookPayload;
|
||||||
|
const paymentHash = typeof body.payment_hash === "string" ? body.payment_hash.trim() : null;
|
||||||
|
if (!paymentHash) {
|
||||||
|
res.status(400).json({ error: "Missing payment_hash" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const sponsor = await db.getSponsorByPaymentHash(paymentHash);
|
||||||
|
if (!sponsor || sponsor.status !== "pending_payment") {
|
||||||
|
res.status(200).json({ ok: true, message: "No matching sponsor or already processed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
if (sponsor.extends_sponsor_id) {
|
||||||
|
const additionalSeconds = sponsor.duration_days * 86400;
|
||||||
|
await db.updateSponsorExpiresAtAdd(sponsor.extends_sponsor_id, additionalSeconds);
|
||||||
|
await db.updateSponsorStatus(sponsor.id, "removed");
|
||||||
|
console.log(`[sponsor] Extended sponsor id=${sponsor.extends_sponsor_id} by ${sponsor.duration_days} days`);
|
||||||
|
} else {
|
||||||
|
const expiresAt = now + sponsor.duration_days * 86400;
|
||||||
|
const updated = await db.updateSponsorOnPayment(paymentHash, now, expiresAt);
|
||||||
|
if (updated) {
|
||||||
|
console.log(`[sponsor] Activated sponsor id=${sponsor.id} title="${sponsor.title}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[sponsor/webhook]", e);
|
||||||
|
res.status(500).json({ error: "Internal error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
61
backend/src/services/fetchOgMeta.ts
Normal file
61
backend/src/services/fetchOgMeta.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Fetch Open Graph meta tags from a URL.
|
||||||
|
* Used when sponsor image_url is empty - we try to get og:image from the target site.
|
||||||
|
*/
|
||||||
|
export async function fetchOgMeta(url: string): Promise<{ image?: string; title?: string; description?: string }> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "SatsFaucet/1.0 (Sponsor preview fetcher)",
|
||||||
|
},
|
||||||
|
redirect: "follow",
|
||||||
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (!res.ok) return {};
|
||||||
|
const html = await res.text();
|
||||||
|
return parseOgFromHtml(html, url);
|
||||||
|
} catch {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOgFromHtml(html: string, baseUrl: string): { image?: string; title?: string; description?: string } {
|
||||||
|
const result: { image?: string; title?: string; description?: string } = {};
|
||||||
|
const ogImage = matchMeta(html, "og:image");
|
||||||
|
const ogTitle = matchMeta(html, "og:title");
|
||||||
|
const ogDesc = matchMeta(html, "og:description");
|
||||||
|
|
||||||
|
if (ogImage) {
|
||||||
|
result.image = resolveUrl(ogImage, baseUrl);
|
||||||
|
}
|
||||||
|
if (ogTitle) result.title = ogTitle;
|
||||||
|
if (ogDesc) result.description = ogDesc;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchMeta(html: string, property: string): string | null {
|
||||||
|
const patterns = [
|
||||||
|
new RegExp(`<meta[^>]+property=["']${property.replace(":", "\\:")}["'][^>]+content=["']([^"']+)["']`, "i"),
|
||||||
|
new RegExp(`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']${property.replace(":", "\\:")}["']`, "i"),
|
||||||
|
];
|
||||||
|
for (const re of patterns) {
|
||||||
|
const m = html.match(re);
|
||||||
|
if (m) return m[1].trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUrl(url: string, base: string): string {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
||||||
|
try {
|
||||||
|
return new URL(trimmed, base).href;
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -188,3 +188,53 @@ export async function getIncomingPaymentsFromLnbits(limit = 100): Promise<
|
|||||||
}
|
}
|
||||||
return incoming;
|
return incoming;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a payment (invoice) has been paid.
|
||||||
|
* LNbits API: GET /api/v1/payments/{payment_hash}
|
||||||
|
*/
|
||||||
|
export async function checkPaymentStatus(paymentHash: string): Promise<{ paid: boolean }> {
|
||||||
|
const res = await fetch(`${base}/api/v1/payments/${encodeURIComponent(paymentHash)}`, {
|
||||||
|
headers: { "X-Api-Key": adminKey },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 404) return { paid: false };
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`LNbits check payment failed: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { paid?: boolean };
|
||||||
|
return { paid: Boolean(data.paid) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an incoming Lightning invoice (receive payment).
|
||||||
|
* LNbits API: POST /api/v1/payments with out: false.
|
||||||
|
*/
|
||||||
|
export async function createInvoice(
|
||||||
|
amountSats: number,
|
||||||
|
memo: string,
|
||||||
|
webhookUrl: string
|
||||||
|
): Promise<{ payment_hash: string; payment_request: string }> {
|
||||||
|
const res = await fetch(`${base}/api/v1/payments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", "X-Api-Key": adminKey },
|
||||||
|
body: JSON.stringify({
|
||||||
|
out: false,
|
||||||
|
amount: amountSats,
|
||||||
|
memo: memo.slice(0, 200),
|
||||||
|
webhook: webhookUrl || undefined,
|
||||||
|
expiry: 3600,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`LNbits create invoice failed: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { payment_hash?: string; payment_request?: string };
|
||||||
|
const paymentHash = data.payment_hash;
|
||||||
|
const paymentRequest = data.payment_request;
|
||||||
|
if (!paymentHash || !paymentRequest) {
|
||||||
|
throw new Error("LNbits invoice response missing payment_hash or payment_request");
|
||||||
|
}
|
||||||
|
return { payment_hash: paymentHash, payment_request: paymentRequest };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Backend API URL (required in dev when frontend runs on different port)
|
# Backend API URL. For dev: leave empty to use Vite proxy (recommended).
|
||||||
# Leave empty if frontend is served from same origin as API
|
# For production or when frontend is on different origin: set full URL e.g. http://localhost:3001
|
||||||
VITE_API_URL=http://localhost:3001
|
# VITE_API_URL=
|
||||||
|
|
||||||
# Nostr relays for fetching user profile metadata (comma-separated)
|
# Nostr relays for fetching user profile metadata (comma-separated)
|
||||||
VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://relay.nostr.band,wss://nos.lol
|
VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://relay.nostr.band,wss://nos.lol
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { ClaimWizard } from "./components/ClaimWizard";
|
|||||||
import { StatsSection } from "./components/StatsSection";
|
import { StatsSection } from "./components/StatsSection";
|
||||||
import { DepositSection } from "./components/DepositSection";
|
import { DepositSection } from "./components/DepositSection";
|
||||||
import { TransactionsPage } from "./pages/TransactionsPage";
|
import { TransactionsPage } from "./pages/TransactionsPage";
|
||||||
|
import { SponsorsPage } from "./pages/SponsorsPage";
|
||||||
|
import { MyAdsPage } from "./pages/MyAdsPage";
|
||||||
|
import { SponsorsSection } from "./components/SponsorsSection";
|
||||||
|
|
||||||
const FaucetSvg = () => (
|
const FaucetSvg = () => (
|
||||||
<svg className="faucet-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg">
|
<svg className="faucet-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -29,9 +32,13 @@ export default function App() {
|
|||||||
const [loginMethod, setLoginMethod] = useState<LoginMethod | null>(null);
|
const [loginMethod, setLoginMethod] = useState<LoginMethod | null>(null);
|
||||||
const [statsRefetchTrigger, setStatsRefetchTrigger] = useState(0);
|
const [statsRefetchTrigger, setStatsRefetchTrigger] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
const refreshAuth = useCallback(() => {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) return;
|
if (!token) {
|
||||||
|
setPubkey(null);
|
||||||
|
setLoginMethod(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
getAuthMe()
|
getAuthMe()
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
setPubkey(r.pubkey);
|
setPubkey(r.pubkey);
|
||||||
@@ -44,6 +51,13 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshAuth();
|
||||||
|
const onAuthChanged = () => refreshAuth();
|
||||||
|
window.addEventListener("auth-changed", onAuthChanged);
|
||||||
|
return () => window.removeEventListener("auth-changed", onAuthChanged);
|
||||||
|
}, [refreshAuth]);
|
||||||
|
|
||||||
const handlePubkeyChange = useCallback((pk: string | null, method?: LoginMethod) => {
|
const handlePubkeyChange = useCallback((pk: string | null, method?: LoginMethod) => {
|
||||||
setPubkey(pk);
|
setPubkey(pk);
|
||||||
setLoginMethod(pk ? (method ?? "nip98") : null);
|
setLoginMethod(pk ? (method ?? "nip98") : null);
|
||||||
@@ -62,28 +76,19 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<Header pubkey={pubkey} onLogout={handleLogout} />
|
<Header pubkey={pubkey} onLogout={handleLogout} onLoginSuccess={handlePubkeyChange} />
|
||||||
<div className="topbar" />
|
<div className="topbar" />
|
||||||
<div className="app-body">
|
<div className="app-body">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
|
<>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<aside className="sidebar sidebar-left">
|
<aside className="sidebar sidebar-left">
|
||||||
<div className="funding-panel">
|
<div className="funding-panel">
|
||||||
<DepositSection />
|
<DepositSection />
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-links">
|
|
||||||
<p className="sidebar-links-title">Sats available</p>
|
|
||||||
<p className="label">Other Sites:</p>
|
|
||||||
<a href="https://bitcoin.org/en/" target="_blank" rel="noopener noreferrer">
|
|
||||||
Bitcoin.org
|
|
||||||
</a>
|
|
||||||
<a href="https://bitcoin.org/en/buy" target="_blank" rel="noopener noreferrer">
|
|
||||||
Bitcoin Market
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
<main className="main">
|
<main className="main">
|
||||||
<div className="header">
|
<div className="header">
|
||||||
@@ -96,6 +101,8 @@ export default function App() {
|
|||||||
<StatsSection refetchTrigger={statsRefetchTrigger} />
|
<StatsSection refetchTrigger={statsRefetchTrigger} />
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
<SponsorsSection />
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
@@ -108,6 +115,24 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/sponsors"
|
||||||
|
element={
|
||||||
|
<div className="sponsors-route">
|
||||||
|
<SponsorsPage />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/my-ads"
|
||||||
|
element={
|
||||||
|
<div className="container container--single">
|
||||||
|
<main className="main main--full">
|
||||||
|
<MyAdsPage />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export interface Stats {
|
|||||||
spentTodaySats: number;
|
spentTodaySats: number;
|
||||||
recentPayouts: { pubkey_prefix: string; payout_sats: number; claimed_at: number }[];
|
recentPayouts: { pubkey_prefix: string; payout_sats: number; claimed_at: number }[];
|
||||||
recentDeposits: { amount_sats: number; source: DepositSource; created_at: number }[];
|
recentDeposits: { amount_sats: number; source: DepositSource; created_at: number }[];
|
||||||
|
recentSponsorPayments?: { created_at: number; amount_sats: number; title: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DepositInfo {
|
export interface DepositInfo {
|
||||||
@@ -319,3 +320,120 @@ export async function postUserRefreshProfile(): Promise<UserProfile> {
|
|||||||
export function hasNostr(): boolean {
|
export function hasNostr(): boolean {
|
||||||
return Boolean(typeof window !== "undefined" && window.nostr);
|
return Boolean(typeof window !== "undefined" && window.nostr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sponsor API
|
||||||
|
export interface SponsorHomepageItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
image_url: string | null;
|
||||||
|
link_url: string;
|
||||||
|
category: string | null;
|
||||||
|
expires_at: number | null;
|
||||||
|
views: number;
|
||||||
|
clicks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SponsorListItem extends SponsorHomepageItem {}
|
||||||
|
|
||||||
|
export interface SponsorMyAd {
|
||||||
|
id: number;
|
||||||
|
npub: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
image_url: string | null;
|
||||||
|
link_url: string;
|
||||||
|
category: string | null;
|
||||||
|
lightning_address: string | null;
|
||||||
|
status: string;
|
||||||
|
created_at: number;
|
||||||
|
activated_at: number | null;
|
||||||
|
expires_at: number | null;
|
||||||
|
price_sats: number;
|
||||||
|
duration_days: number;
|
||||||
|
views: number;
|
||||||
|
clicks: number;
|
||||||
|
payment_hash?: string | null;
|
||||||
|
payment_request?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SponsorExtendResult {
|
||||||
|
sponsor_id: number;
|
||||||
|
payment_hash: string;
|
||||||
|
payment_request: string;
|
||||||
|
price_sats: number;
|
||||||
|
duration_days: number;
|
||||||
|
new_expires_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SponsorCreateResult {
|
||||||
|
id: number;
|
||||||
|
payment_hash: string;
|
||||||
|
payment_request: string;
|
||||||
|
price_sats: number;
|
||||||
|
duration_days: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSponsorHomepage(): Promise<SponsorHomepageItem[]> {
|
||||||
|
return request<SponsorHomepageItem[]>("/sponsor/homepage");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSponsorList(): Promise<SponsorListItem[]> {
|
||||||
|
return request<SponsorListItem[]>("/sponsor/list");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSponsorMyAds(): Promise<SponsorMyAd[]> {
|
||||||
|
if (getToken()) return requestWithBearer<SponsorMyAd[]>("GET", "/sponsor/my-ads");
|
||||||
|
return requestWithNip98<SponsorMyAd[]>("GET", "/sponsor/my-ads");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postSponsorCreate(body: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
link_url: string;
|
||||||
|
image_url?: string;
|
||||||
|
duration_days: number;
|
||||||
|
}): Promise<SponsorCreateResult> {
|
||||||
|
if (getToken()) return requestWithBearer<SponsorCreateResult>("POST", "/sponsor/create", body);
|
||||||
|
return requestWithNip98<SponsorCreateResult>("POST", "/sponsor/create", body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchSponsorView(id: number): Promise<void> {
|
||||||
|
await fetch(apiUrl(`/sponsor/${id}/view`), { method: "PATCH" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSponsorClickUrl(id: number): string {
|
||||||
|
return apiUrl(`/sponsor/click/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSponsorCheckPayment(paymentHash: string): Promise<{ paid: boolean }> {
|
||||||
|
const data = await request<{ paid: boolean }>(`/sponsor/check-payment/${encodeURIComponent(paymentHash)}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postSponsorExtend(
|
||||||
|
sponsorId: number,
|
||||||
|
durationDays: number
|
||||||
|
): Promise<SponsorExtendResult> {
|
||||||
|
if (getToken()) {
|
||||||
|
return requestWithBearer<SponsorExtendResult>("PATCH", `/sponsor/${sponsorId}/extend`, {
|
||||||
|
duration_days: durationDays,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return requestWithNip98<SponsorExtendResult>("PATCH", `/sponsor/${sponsorId}/extend`, {
|
||||||
|
duration_days: durationDays,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postSponsorRegenerateInvoice(sponsorId: number): Promise<{
|
||||||
|
payment_hash: string;
|
||||||
|
payment_request: string;
|
||||||
|
price_sats: number;
|
||||||
|
duration_days: number;
|
||||||
|
}> {
|
||||||
|
if (getToken()) {
|
||||||
|
return requestWithBearer("POST", `/sponsor/${sponsorId}/regenerate-invoice`);
|
||||||
|
}
|
||||||
|
return requestWithNip98("POST", `/sponsor/${sponsorId}/regenerate-invoice`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { useState, useRef, useEffect, useCallback } from "react";
|
|||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { useNostrProfile } from "../hooks/useNostrProfile";
|
import { useNostrProfile } from "../hooks/useNostrProfile";
|
||||||
import { nip19 } from "nostr-tools";
|
import { nip19 } from "nostr-tools";
|
||||||
|
import { LoginModal } from "./LoginModal";
|
||||||
|
import type { LoginMethod } from "../api";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
pubkey: string | null;
|
pubkey: string | null;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
|
onLoginSuccess?: (pubkey: string, method: LoginMethod) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function truncatedNpub(pubkey: string): string {
|
function truncatedNpub(pubkey: string): string {
|
||||||
@@ -13,15 +16,31 @@ function truncatedNpub(pubkey: string): string {
|
|||||||
return npub.slice(0, 12) + "..." + npub.slice(-4);
|
return npub.slice(0, 12) + "..." + npub.slice(-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ pubkey, onLogout }: HeaderProps) {
|
const navLinks = [
|
||||||
|
{ to: "/", label: "Home" },
|
||||||
|
{ to: "/transactions", label: "Transactions" },
|
||||||
|
{ to: "/sponsors", label: "Sponsors" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function Header({ pubkey, onLogout, onLoginSuccess }: HeaderProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const profile = useNostrProfile(pubkey);
|
const profile = useNostrProfile(pubkey);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const drawerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const displayName = profile?.display_name || profile?.name || (pubkey ? truncatedNpub(pubkey) : null);
|
const displayName = profile?.display_name || profile?.name || (pubkey ? truncatedNpub(pubkey) : null);
|
||||||
|
|
||||||
const handleToggle = useCallback(() => setMenuOpen((o) => !o), []);
|
const handleToggle = useCallback(() => setMenuOpen((o) => !o), []);
|
||||||
|
const handleDrawerToggle = useCallback(() => setDrawerOpen((o) => !o), []);
|
||||||
|
|
||||||
|
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
closeDrawer();
|
||||||
|
}, [location.pathname, closeDrawer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menuOpen) return;
|
if (!menuOpen) return;
|
||||||
@@ -41,33 +60,78 @@ export function Header({ pubkey, onLogout }: HeaderProps) {
|
|||||||
};
|
};
|
||||||
}, [menuOpen]);
|
}, [menuOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!drawerOpen) return;
|
||||||
|
function onClickOutside(e: MouseEvent) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (drawerRef.current && !drawerRef.current.contains(target) && !target.closest(".header-hamburger")) {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onEscape(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") setDrawerOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onClickOutside);
|
||||||
|
document.addEventListener("keydown", onEscape);
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onClickOutside);
|
||||||
|
document.removeEventListener("keydown", onEscape);
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [drawerOpen]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
|
setDrawerOpen(false);
|
||||||
onLogout?.();
|
onLogout?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="site-header">
|
<header className={`site-header${drawerOpen ? " site-header--drawer-open" : ""}`}>
|
||||||
<div className="site-header-inner">
|
<div className="site-header-inner">
|
||||||
<Link to="/" className="site-logo">
|
<Link to="/" className="site-logo">
|
||||||
<span className="site-logo-text">Sats Faucet</span>
|
<span className="site-logo-text">Sats Faucet</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="site-header-right">
|
|
||||||
<nav className="site-nav" aria-label="Main navigation">
|
<button
|
||||||
<Link
|
type="button"
|
||||||
to="/"
|
className="header-hamburger"
|
||||||
className={location.pathname === "/" ? "site-nav-link active" : "site-nav-link"}
|
onClick={handleDrawerToggle}
|
||||||
|
aria-expanded={drawerOpen}
|
||||||
|
aria-controls="header-drawer"
|
||||||
|
aria-label={drawerOpen ? "Close menu" : "Open menu"}
|
||||||
>
|
>
|
||||||
Home
|
<span className="header-hamburger-bar" />
|
||||||
</Link>
|
<span className="header-hamburger-bar" />
|
||||||
|
<span className="header-hamburger-bar" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="site-header-right" ref={drawerRef}>
|
||||||
|
<nav className="site-nav" aria-label="Main navigation" id="header-drawer">
|
||||||
|
{navLinks.map(({ to, label }) => (
|
||||||
<Link
|
<Link
|
||||||
to="/transactions"
|
key={to}
|
||||||
className={location.pathname === "/transactions" ? "site-nav-link active" : "site-nav-link"}
|
to={to}
|
||||||
|
className={location.pathname === to ? "site-nav-link active" : "site-nav-link"}
|
||||||
|
onClick={closeDrawer}
|
||||||
>
|
>
|
||||||
Transactions
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
{pubkey && (
|
{!pubkey ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="header-login-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setLoginModalOpen(true);
|
||||||
|
closeDrawer();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Login with Nostr
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<div className="header-user" ref={menuRef}>
|
<div className="header-user" ref={menuRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -100,6 +164,12 @@ export function Header({ pubkey, onLogout }: HeaderProps) {
|
|||||||
<span className="header-user-menu-npub">{truncatedNpub(pubkey)}</span>
|
<span className="header-user-menu-npub">{truncatedNpub(pubkey)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-user-menu-divider" />
|
<div className="header-user-menu-divider" />
|
||||||
|
<Link to="/my-ads" className="header-user-menu-item" role="menuitem" onClick={() => { setMenuOpen(false); closeDrawer(); }}>
|
||||||
|
My Ads
|
||||||
|
</Link>
|
||||||
|
<Link to="/sponsors" className="header-user-menu-item" role="menuitem" onClick={() => { setMenuOpen(false); closeDrawer(); }}>
|
||||||
|
Create Sponsor
|
||||||
|
</Link>
|
||||||
<button type="button" className="header-user-menu-item" role="menuitem" onClick={handleLogout}>
|
<button type="button" className="header-user-menu-item" role="menuitem" onClick={handleLogout}>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
|
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
|
||||||
@@ -114,6 +184,22 @@ export function Header({ pubkey, onLogout }: HeaderProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{drawerOpen && (
|
||||||
|
<div
|
||||||
|
className="header-drawer-overlay"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={closeDrawer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<LoginModal
|
||||||
|
open={loginModalOpen}
|
||||||
|
onClose={() => setLoginModalOpen(false)}
|
||||||
|
onSuccess={(pk, method) => {
|
||||||
|
setLoginModalOpen(false);
|
||||||
|
onLoginSuccess?.(pk, method);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
|
|||||||
setToken(token);
|
setToken(token);
|
||||||
postUserRefreshProfile().catch(() => {});
|
postUserRefreshProfile().catch(() => {});
|
||||||
onSuccess(pubkey, method ?? "nip98");
|
onSuccess(pubkey, method ?? "nip98");
|
||||||
|
window.dispatchEvent(new CustomEvent("auth-changed"));
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : (e as ApiError)?.message ?? "Login failed";
|
const msg = e instanceof Error ? e.message : (e as ApiError)?.message ?? "Login failed";
|
||||||
@@ -164,7 +165,7 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={handleClose} title="Log in with Nostr" preventClose={loading}>
|
<Modal open={open} onClose={handleClose} title="Log in with Nostr" preventClose={loading} variant="login">
|
||||||
<div className="login-modal-tabs">
|
<div className="login-modal-tabs">
|
||||||
{(["extension", "remote", "nsec"] as const).map((t) => (
|
{(["extension", "remote", "nsec"] as const).map((t) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ interface ModalProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/** If true, do not close on overlay click (e.g. when loading). */
|
/** If true, do not close on overlay click (e.g. when loading). */
|
||||||
preventClose?: boolean;
|
preventClose?: boolean;
|
||||||
|
/** Optional variant for styling (e.g. "sponsor" for larger modals). */
|
||||||
|
variant?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FOCUSABLE = "button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])";
|
const FOCUSABLE = "button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])";
|
||||||
@@ -18,7 +20,7 @@ function getFocusables(container: HTMLElement): HTMLElement[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({ open, onClose, title, children, preventClose }: ModalProps) {
|
export function Modal({ open, onClose, title, children, preventClose, variant }: ModalProps) {
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
const reduceMotion = useReducedMotion();
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ export function Modal({ open, onClose, title, children, preventClose }: ModalPro
|
|||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
className="modal"
|
className={`modal${variant ? ` modal--${variant}` : ""}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
initial={reduceMotion ? false : { opacity: 0, scale: 0.98 }}
|
initial={reduceMotion ? false : { opacity: 0, scale: 0.98 }}
|
||||||
|
|||||||
84
frontend/src/components/SponsorCard.tsx
Normal file
84
frontend/src/components/SponsorCard.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { getSponsorClickUrl, patchSponsorView, type SponsorHomepageItem } from "../api";
|
||||||
|
|
||||||
|
interface SponsorCardProps {
|
||||||
|
sponsor: SponsorHomepageItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDaysLeft(expiresAt: number | null): number {
|
||||||
|
if (!expiresAt) return 0;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return Math.max(0, Math.ceil((expiresAt - now) / 86400));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDomain(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
return u.hostname.replace(/^www\./, "");
|
||||||
|
} catch {
|
||||||
|
return "Sponsor";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SponsorCard({ sponsor }: SponsorCardProps) {
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
|
const viewedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = cardRef.current;
|
||||||
|
if (!el || viewedRef.current) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting && !viewedRef.current) {
|
||||||
|
viewedRef.current = true;
|
||||||
|
patchSponsorView(sponsor.id).catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.5 }
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [sponsor.id]);
|
||||||
|
|
||||||
|
const daysLeft = getDaysLeft(sponsor.expires_at);
|
||||||
|
const clickUrl = getSponsorClickUrl(sponsor.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article ref={cardRef} className="sponsor-card">
|
||||||
|
<a
|
||||||
|
href={clickUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="sponsor-card-link"
|
||||||
|
aria-label={`Visit ${sponsor.title}`}
|
||||||
|
>
|
||||||
|
<div className="sponsor-card-image-wrap">
|
||||||
|
{sponsor.image_url ? (
|
||||||
|
<img
|
||||||
|
src={sponsor.image_url}
|
||||||
|
alt=""
|
||||||
|
className="sponsor-card-image"
|
||||||
|
loading="lazy"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
(e.target as HTMLImageElement).nextElementSibling?.classList.remove("hidden");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className={`sponsor-card-fallback ${sponsor.image_url ? "hidden" : ""}`}>
|
||||||
|
<span className="sponsor-card-fallback-icon">🔗</span>
|
||||||
|
<span className="sponsor-card-fallback-domain">{extractDomain(sponsor.link_url)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sponsor-card-body">
|
||||||
|
<h3 className="sponsor-card-title">{sponsor.title}</h3>
|
||||||
|
<p className="sponsor-card-desc">{sponsor.description}</p>
|
||||||
|
<span className="sponsor-card-cta">Visit sponsor</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<div className="sponsor-card-meta">
|
||||||
|
<span className="sponsor-card-days">{daysLeft} days left</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
frontend/src/components/SponsorForm.tsx
Normal file
145
frontend/src/components/SponsorForm.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
import { postSponsorCreate, type SponsorCreateResult } from "../api";
|
||||||
|
import { SponsorTimeSlider } from "./SponsorTimeSlider";
|
||||||
|
|
||||||
|
const BASE_PRICE = 200;
|
||||||
|
|
||||||
|
function calculatePrice(days: number): number {
|
||||||
|
let price = BASE_PRICE * days;
|
||||||
|
if (days >= 180) price *= 0.7;
|
||||||
|
else if (days >= 90) price *= 0.8;
|
||||||
|
else if (days >= 30) price *= 0.9;
|
||||||
|
return Math.round(price);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SponsorFormProps {
|
||||||
|
onSuccess?: (result: SponsorCreateResult) => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SponsorForm({ onSuccess, onCancel }: SponsorFormProps) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [linkUrl, setLinkUrl] = useState("");
|
||||||
|
const [imageUrl, setImageUrl] = useState("");
|
||||||
|
const [durationDays, setDurationDays] = useState(30);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const priceSats = useMemo(() => calculatePrice(durationDays), [durationDays]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError("Title is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!description.trim()) {
|
||||||
|
setError("Description is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!linkUrl.trim() || !/^https?:\/\/.+/.test(linkUrl)) {
|
||||||
|
setError("Valid URL (https://...) is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await postSponsorCreate({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
link_url: linkUrl.trim(),
|
||||||
|
image_url: imageUrl.trim() || undefined,
|
||||||
|
duration_days: durationDays,
|
||||||
|
});
|
||||||
|
onSuccess?.(result);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to create sponsor");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[title, description, linkUrl, imageUrl, durationDays, onSuccess]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="sponsor-form" onSubmit={handleSubmit}>
|
||||||
|
<div className="sponsor-form-row">
|
||||||
|
<label htmlFor="sponsor-title" className="sponsor-form-label">
|
||||||
|
Title <span className="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="sponsor-title"
|
||||||
|
type="text"
|
||||||
|
className="sponsor-form-input"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Your project or product name"
|
||||||
|
maxLength={100}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sponsor-form-row">
|
||||||
|
<label htmlFor="sponsor-desc" className="sponsor-form-label">
|
||||||
|
Short description <span className="required">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="sponsor-desc"
|
||||||
|
className="sponsor-form-textarea"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Brief description (max 500 chars)"
|
||||||
|
maxLength={500}
|
||||||
|
rows={3}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sponsor-form-row">
|
||||||
|
<label htmlFor="sponsor-link" className="sponsor-form-label">
|
||||||
|
Destination URL <span className="required">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="sponsor-link"
|
||||||
|
type="url"
|
||||||
|
className="sponsor-form-input"
|
||||||
|
value={linkUrl}
|
||||||
|
onChange={(e) => setLinkUrl(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sponsor-form-row">
|
||||||
|
<label htmlFor="sponsor-image" className="sponsor-form-label">
|
||||||
|
Image URL <span className="optional">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="sponsor-image"
|
||||||
|
type="url"
|
||||||
|
className="sponsor-form-input"
|
||||||
|
value={imageUrl}
|
||||||
|
onChange={(e) => setImageUrl(e.target.value)}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sponsor-form-row">
|
||||||
|
<SponsorTimeSlider value={durationDays} onChange={setDurationDays} />
|
||||||
|
</div>
|
||||||
|
<div className="sponsor-form-price">
|
||||||
|
<span className="sponsor-form-price-label">Total:</span>
|
||||||
|
<strong className="sponsor-form-price-value">{priceSats.toLocaleString()} sats</strong>
|
||||||
|
</div>
|
||||||
|
{error && <p className="sponsor-form-error" role="alert">{error}</p>}
|
||||||
|
<div className="sponsor-form-actions">
|
||||||
|
{onCancel && (
|
||||||
|
<button type="button" className="sponsor-form-btn secondary" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="submit" className="sponsor-form-btn primary" disabled={loading}>
|
||||||
|
{loading ? "Creating…" : "Create & Pay"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
frontend/src/components/SponsorInvoiceModal.tsx
Normal file
98
frontend/src/components/SponsorInvoiceModal.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
import { Modal } from "./Modal";
|
||||||
|
import { getSponsorCheckPayment } from "../api";
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 10_000;
|
||||||
|
const INVOICE_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
|
||||||
|
export interface PendingInvoice {
|
||||||
|
payment_hash: string;
|
||||||
|
payment_request: string;
|
||||||
|
price_sats: number;
|
||||||
|
duration_days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SponsorInvoiceModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
result: PendingInvoice | null;
|
||||||
|
/** Called when payment is detected (e.g. to refresh sponsor list). */
|
||||||
|
onPaid?: () => void;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SponsorInvoiceModal({ open, onClose, result, onPaid, title }: SponsorInvoiceModalProps) {
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||||
|
const onPaidRef = useRef(onPaid);
|
||||||
|
const onCloseRef = useRef(onClose);
|
||||||
|
onPaidRef.current = onPaid;
|
||||||
|
onCloseRef.current = onClose;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!result?.payment_request) {
|
||||||
|
setQrDataUrl(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QRCode.toDataURL(result.payment_request, { width: 200 }).then(setQrDataUrl).catch(() => setQrDataUrl(null));
|
||||||
|
}, [result?.payment_request]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !result?.payment_hash) return;
|
||||||
|
const openedAt = Date.now();
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const check = async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (Date.now() - openedAt > INVOICE_EXPIRY_MS) return;
|
||||||
|
try {
|
||||||
|
const { paid: isPaid } = await getSponsorCheckPayment(result!.payment_hash);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (isPaid) {
|
||||||
|
onPaidRef.current?.();
|
||||||
|
onCloseRef.current();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore; will retry on next poll
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
check();
|
||||||
|
const interval = setInterval(check, POLL_INTERVAL_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [open, result?.payment_hash]);
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (!result?.payment_request) return;
|
||||||
|
navigator.clipboard.writeText(result.payment_request).then(() => {
|
||||||
|
// Could show a toast
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={title ?? "Pay to activate sponsor"} variant="sponsor">
|
||||||
|
<div className="sponsor-invoice">
|
||||||
|
<p className="sponsor-invoice-desc">
|
||||||
|
Scan or copy the Lightning invoice to pay <strong>{result.price_sats.toLocaleString()} sats</strong>.
|
||||||
|
Your sponsor will activate after payment.
|
||||||
|
</p>
|
||||||
|
{qrDataUrl && (
|
||||||
|
<div className="sponsor-invoice-qr">
|
||||||
|
<img src={qrDataUrl} alt="Lightning invoice QR" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" className="sponsor-invoice-copy" onClick={handleCopy}>
|
||||||
|
Copy invoice
|
||||||
|
</button>
|
||||||
|
<p className="sponsor-invoice-note">
|
||||||
|
Duration: {result.duration_days} days.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/src/components/SponsorTimeSlider.tsx
Normal file
66
frontend/src/components/SponsorTimeSlider.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const SNAP_DAYS = [1, 3, 7, 14, 30, 60, 90, 180, 365];
|
||||||
|
|
||||||
|
function snapToNearest(value: number): number {
|
||||||
|
let best = SNAP_DAYS[0];
|
||||||
|
for (const d of SNAP_DAYS) {
|
||||||
|
if (Math.abs(d - value) < Math.abs(best - value)) best = d;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SponsorTimeSliderProps {
|
||||||
|
value: number;
|
||||||
|
onChange: (days: number) => void;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SponsorTimeSlider({ value, onChange, min = 1, max = 365 }: SponsorTimeSliderProps) {
|
||||||
|
const [internalValue, setInternalValue] = useState(value);
|
||||||
|
const snapped = useMemo(() => snapToNearest(internalValue), [internalValue]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const raw = parseInt(e.target.value, 10);
|
||||||
|
const clamped = Math.min(max, Math.max(min, Number.isFinite(raw) ? raw : min));
|
||||||
|
setInternalValue(clamped);
|
||||||
|
const days = snapToNearest(clamped);
|
||||||
|
onChange(days);
|
||||||
|
},
|
||||||
|
[min, max, onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sponsor-time-slider">
|
||||||
|
<label className="sponsor-time-slider-label">
|
||||||
|
Display duration: <strong>{snapped} days</strong>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="sponsor-time-slider-input"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={internalValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
aria-label="Sponsor display duration in days"
|
||||||
|
/>
|
||||||
|
<div className="sponsor-time-slider-marks">
|
||||||
|
{SNAP_DAYS.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
type="button"
|
||||||
|
className={`sponsor-time-slider-mark ${snapped === d ? "active" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setInternalValue(d);
|
||||||
|
onChange(d);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{d}d
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
frontend/src/components/SponsorsSection.tsx
Normal file
31
frontend/src/components/SponsorsSection.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { getSponsorHomepage } from "../api";
|
||||||
|
import { SponsorCard } from "./SponsorCard";
|
||||||
|
import type { SponsorHomepageItem } from "../api";
|
||||||
|
|
||||||
|
export function SponsorsSection() {
|
||||||
|
const [sponsors, setSponsors] = useState<SponsorHomepageItem[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getSponsorHomepage()
|
||||||
|
.then(setSponsors)
|
||||||
|
.catch(() => setSponsors([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (sponsors.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="homepage-sponsors">
|
||||||
|
<h2 className="homepage-sponsors-title">Sponsors</h2>
|
||||||
|
<div className="sponsors-grid">
|
||||||
|
{sponsors.map((s) => (
|
||||||
|
<SponsorCard key={s.id} sponsor={s} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="homepage-sponsors-link">
|
||||||
|
<Link to="/sponsors">View all sponsors · Sponsor the Faucet</Link>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
frontend/src/pages/MyAdsPage.tsx
Normal file
205
frontend/src/pages/MyAdsPage.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { getSponsorMyAds, getToken, postSponsorRegenerateInvoice } from "../api";
|
||||||
|
import { SponsorInvoiceModal } from "../components/SponsorInvoiceModal";
|
||||||
|
import type { SponsorMyAd } from "../api";
|
||||||
|
|
||||||
|
function formatStatus(s: string): string {
|
||||||
|
return s.replace(/_/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDaysLeft(expiresAt: number | null): number {
|
||||||
|
if (!expiresAt) return 0;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return Math.max(0, Math.ceil((expiresAt - now) / 86400));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyAdsPage() {
|
||||||
|
const [ads, setAds] = useState<SponsorMyAd[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
|
||||||
|
const [pendingInvoice, setPendingInvoice] = useState<{
|
||||||
|
payment_hash: string;
|
||||||
|
payment_request: string;
|
||||||
|
price_sats: number;
|
||||||
|
duration_days: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [invoiceLoading, setInvoiceLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "My Ads — Sats Faucet";
|
||||||
|
return () => { document.title = "Sats Faucet — Free Bitcoin for Nostr Users"; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
setError("Please log in to view your ads.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
getSponsorMyAds()
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled) setAds(list);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshAds = useCallback(() => {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
getSponsorMyAds()
|
||||||
|
.then(setAds)
|
||||||
|
.catch(() => setAds([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePayInvoice = useCallback(async (ad: SponsorMyAd) => {
|
||||||
|
if (ad.payment_request && ad.payment_hash) {
|
||||||
|
setPendingInvoice({
|
||||||
|
payment_hash: ad.payment_hash,
|
||||||
|
payment_request: ad.payment_request,
|
||||||
|
price_sats: ad.price_sats,
|
||||||
|
duration_days: ad.duration_days,
|
||||||
|
});
|
||||||
|
setInvoiceModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInvoiceLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await postSponsorRegenerateInvoice(ad.id);
|
||||||
|
setPendingInvoice(result);
|
||||||
|
setInvoiceModalOpen(true);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Failed to load invoice");
|
||||||
|
} finally {
|
||||||
|
setInvoiceLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!getToken()) {
|
||||||
|
return (
|
||||||
|
<div className="my-ads-page">
|
||||||
|
<h1>My Ads</h1>
|
||||||
|
<p>Please <Link to="/">log in</Link> to view your sponsor ads.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-ads-page">
|
||||||
|
<header className="my-ads-header">
|
||||||
|
<h1>My Ads</h1>
|
||||||
|
<p>Manage your sponsor placements.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading && <div className="my-ads-loading">Loading…</div>}
|
||||||
|
{error && <p className="my-ads-error">{error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{ads.length === 0 ? (
|
||||||
|
<div className="my-ads-empty">
|
||||||
|
<p>You have no sponsor ads yet.</p>
|
||||||
|
<Link to="/sponsors" className="my-ads-create-link">Create a sponsor</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Desktop: table */}
|
||||||
|
<div className="my-ads-table-wrap">
|
||||||
|
<table className="my-ads-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Time left</th>
|
||||||
|
<th>Views</th>
|
||||||
|
<th>Clicks</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ads.map((ad) => (
|
||||||
|
<tr key={ad.id}>
|
||||||
|
<td>{ad.title}</td>
|
||||||
|
<td><span className={`my-ads-status my-ads-status--${ad.status}`}>{formatStatus(ad.status)}</span></td>
|
||||||
|
<td>{getDaysLeft(ad.expires_at)} days</td>
|
||||||
|
<td>{ad.views}</td>
|
||||||
|
<td>{ad.clicks}</td>
|
||||||
|
<td>
|
||||||
|
{ad.status === "active" || ad.status === "expired" ? (
|
||||||
|
<Link to={`/sponsors?extend=${ad.id}`}>Extend</Link>
|
||||||
|
) : ad.status === "pending_payment" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="my-ads-pay-btn"
|
||||||
|
onClick={() => handlePayInvoice(ad)}
|
||||||
|
disabled={invoiceLoading}
|
||||||
|
>
|
||||||
|
{invoiceLoading ? "Loading…" : "Pay invoice"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: stacked cards */}
|
||||||
|
<div className="my-ads-cards-mobile">
|
||||||
|
{ads.map((ad) => (
|
||||||
|
<article key={ad.id} className="my-ads-mobile-card">
|
||||||
|
<div className="my-ads-mobile-row1">
|
||||||
|
<h3 className="my-ads-mobile-title">{ad.title}</h3>
|
||||||
|
<span className={`my-ads-status my-ads-status--${ad.status}`}>{formatStatus(ad.status)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="my-ads-mobile-row2">
|
||||||
|
<span className="my-ads-mobile-meta">{getDaysLeft(ad.expires_at)} days left</span>
|
||||||
|
<span className="my-ads-mobile-meta">{ad.views} views · {ad.clicks} clicks</span>
|
||||||
|
</div>
|
||||||
|
<div className="my-ads-mobile-actions">
|
||||||
|
{ad.status === "active" || ad.status === "expired" ? (
|
||||||
|
<Link to={`/sponsors?extend=${ad.id}`} className="my-ads-mobile-action-link">Extend</Link>
|
||||||
|
) : ad.status === "pending_payment" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="my-ads-mobile-action-link"
|
||||||
|
onClick={() => handlePayInvoice(ad)}
|
||||||
|
disabled={invoiceLoading}
|
||||||
|
>
|
||||||
|
{invoiceLoading ? "Loading…" : "Pay invoice"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SponsorInvoiceModal
|
||||||
|
open={invoiceModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setInvoiceModalOpen(false);
|
||||||
|
setPendingInvoice(null);
|
||||||
|
}}
|
||||||
|
result={pendingInvoice}
|
||||||
|
onPaid={refreshAds}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
231
frontend/src/pages/SponsorsPage.tsx
Normal file
231
frontend/src/pages/SponsorsPage.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { getSponsorList, getToken, postSponsorExtend } from "../api";
|
||||||
|
import { SponsorCard } from "../components/SponsorCard";
|
||||||
|
import { SponsorForm } from "../components/SponsorForm";
|
||||||
|
import { SponsorInvoiceModal } from "../components/SponsorInvoiceModal";
|
||||||
|
import { SponsorTimeSlider } from "../components/SponsorTimeSlider";
|
||||||
|
import { LoginModal } from "../components/LoginModal";
|
||||||
|
import { Modal } from "../components/Modal";
|
||||||
|
import type { SponsorCreateResult, SponsorListItem } from "../api";
|
||||||
|
|
||||||
|
export function SponsorsPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const extendId = searchParams.get("extend");
|
||||||
|
const [sponsors, setSponsors] = useState<SponsorListItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||||
|
const [invoiceResult, setInvoiceResult] = useState<{
|
||||||
|
payment_hash: string;
|
||||||
|
payment_request: string;
|
||||||
|
price_sats: number;
|
||||||
|
duration_days: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
|
||||||
|
const [extendModalOpen, setExtendModalOpen] = useState(false);
|
||||||
|
const [extendDuration, setExtendDuration] = useState(30);
|
||||||
|
const [extendLoading, setExtendLoading] = useState(false);
|
||||||
|
const [extendError, setExtendError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Sponsors — Sats Faucet";
|
||||||
|
return () => { document.title = "Sats Faucet — Free Bitcoin for Nostr Users"; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshSponsors = () => {
|
||||||
|
setLoading(true);
|
||||||
|
getSponsorList()
|
||||||
|
.then(setSponsors)
|
||||||
|
.catch(() => setSponsors([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
getSponsorList()
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled) setSponsors(list);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setSponsors([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (extendId) {
|
||||||
|
if (getToken()) {
|
||||||
|
setExtendModalOpen(true);
|
||||||
|
} else {
|
||||||
|
setLoginModalOpen(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [extendId]);
|
||||||
|
|
||||||
|
const handleExtendSubmit = async () => {
|
||||||
|
if (!extendId) return;
|
||||||
|
const id = parseInt(extendId, 10);
|
||||||
|
if (!Number.isFinite(id) || id < 1) return;
|
||||||
|
setExtendLoading(true);
|
||||||
|
setExtendError(null);
|
||||||
|
try {
|
||||||
|
const result = await postSponsorExtend(id, extendDuration);
|
||||||
|
setInvoiceResult({
|
||||||
|
payment_hash: result.payment_hash,
|
||||||
|
payment_request: result.payment_request,
|
||||||
|
price_sats: result.price_sats,
|
||||||
|
duration_days: result.duration_days,
|
||||||
|
});
|
||||||
|
setExtendModalOpen(false);
|
||||||
|
setSearchParams({});
|
||||||
|
setInvoiceModalOpen(true);
|
||||||
|
} catch (e) {
|
||||||
|
setExtendError(e instanceof Error ? e.message : "Failed to create extend invoice");
|
||||||
|
} finally {
|
||||||
|
setExtendLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeExtendModal = () => {
|
||||||
|
setExtendModalOpen(false);
|
||||||
|
setExtendError(null);
|
||||||
|
setSearchParams({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateSuccess = (result: SponsorCreateResult) => {
|
||||||
|
setInvoiceResult({
|
||||||
|
payment_hash: result.payment_hash,
|
||||||
|
payment_request: result.payment_request,
|
||||||
|
price_sats: result.price_sats,
|
||||||
|
duration_days: result.duration_days,
|
||||||
|
});
|
||||||
|
setInvoiceModalOpen(true);
|
||||||
|
setFormOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSponsorCtaClick = () => {
|
||||||
|
if (getToken()) {
|
||||||
|
setFormOpen(true);
|
||||||
|
} else {
|
||||||
|
setLoginModalOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sponsors-page">
|
||||||
|
<header className="sponsors-page-header">
|
||||||
|
<h1 className="sponsors-page-title">Sponsors</h1>
|
||||||
|
<p className="sponsors-page-subtitle">
|
||||||
|
Support the faucet and get visibility. Sponsors fund payouts and appear on the homepage.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="sponsors-pricing">
|
||||||
|
<h3>Pricing</h3>
|
||||||
|
<p>Base: 200 sats/day. Discounts: 30+ days 10% off, 90+ days 20% off, 180+ days 30% off.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sponsors-cta-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sponsors-cta-btn"
|
||||||
|
onClick={handleSponsorCtaClick}
|
||||||
|
>
|
||||||
|
Sponsor the Faucet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="sponsors-section">
|
||||||
|
<h2>Active Sponsors</h2>
|
||||||
|
{loading ? (
|
||||||
|
<div className="sponsors-loading">Loading…</div>
|
||||||
|
) : sponsors.length === 0 ? (
|
||||||
|
<div className="sponsors-empty">
|
||||||
|
<p>No active sponsors yet. Be the first!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="sponsors-grid">
|
||||||
|
{sponsors.map((s) => (
|
||||||
|
<SponsorCard key={s.id} sponsor={s} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Modal open={formOpen} onClose={() => setFormOpen(false)} title="Create Sponsor" variant="sponsor">
|
||||||
|
<SponsorForm
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
onCancel={() => setFormOpen(false)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<SponsorInvoiceModal
|
||||||
|
open={invoiceModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setInvoiceModalOpen(false);
|
||||||
|
setInvoiceResult(null);
|
||||||
|
}}
|
||||||
|
result={invoiceResult}
|
||||||
|
onPaid={refreshSponsors}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={extendModalOpen}
|
||||||
|
onClose={closeExtendModal}
|
||||||
|
title="Extend sponsor"
|
||||||
|
variant="sponsor"
|
||||||
|
>
|
||||||
|
<div className="sponsor-extend-form">
|
||||||
|
<p className="sponsor-extend-desc">
|
||||||
|
Add more days to your sponsor placement. Select the duration and pay the invoice.
|
||||||
|
</p>
|
||||||
|
<div className="sponsor-form-row">
|
||||||
|
<SponsorTimeSlider value={extendDuration} onChange={setExtendDuration} />
|
||||||
|
</div>
|
||||||
|
<div className="sponsor-form-price">
|
||||||
|
<span className="sponsor-form-price-label">Total:</span>
|
||||||
|
<strong className="sponsor-form-price-value">
|
||||||
|
{Math.round(200 * extendDuration * (extendDuration >= 180 ? 0.7 : extendDuration >= 90 ? 0.8 : extendDuration >= 30 ? 0.9 : 1)).toLocaleString()} sats
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
{extendError && <p className="sponsor-form-error" role="alert">{extendError}</p>}
|
||||||
|
<div className="sponsor-form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sponsor-form-btn secondary"
|
||||||
|
onClick={closeExtendModal}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sponsor-form-btn primary"
|
||||||
|
onClick={handleExtendSubmit}
|
||||||
|
disabled={extendLoading}
|
||||||
|
>
|
||||||
|
{extendLoading ? "Creating…" : "Get invoice"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<LoginModal
|
||||||
|
open={loginModalOpen}
|
||||||
|
onClose={() => setLoginModalOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setLoginModalOpen(false);
|
||||||
|
if (extendId) {
|
||||||
|
setExtendModalOpen(true);
|
||||||
|
} else {
|
||||||
|
setFormOpen(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { motion, AnimatePresence } from "framer-motion";
|
|||||||
import { getStats, type Stats, type DepositSource } from "../api";
|
import { getStats, type Stats, type DepositSource } from "../api";
|
||||||
|
|
||||||
type TxDirection = "in" | "out";
|
type TxDirection = "in" | "out";
|
||||||
type TxType = "lightning" | "cashu";
|
type TxType = "lightning" | "cashu" | "sponsor";
|
||||||
|
|
||||||
interface UnifiedTx {
|
interface UnifiedTx {
|
||||||
at: number;
|
at: number;
|
||||||
@@ -18,7 +18,7 @@ function formatSource(s: DepositSource): TxType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DirectionFilter = "all" | "in" | "out";
|
type DirectionFilter = "all" | "in" | "out";
|
||||||
type TypeFilter = "all" | "lightning" | "cashu";
|
type TypeFilter = "all" | "lightning" | "cashu" | "sponsor";
|
||||||
type SortOrder = "newest" | "oldest";
|
type SortOrder = "newest" | "oldest";
|
||||||
|
|
||||||
export function TransactionsPage() {
|
export function TransactionsPage() {
|
||||||
@@ -83,7 +83,14 @@ export function TransactionsPage() {
|
|||||||
amount_sats: d.amount_sats,
|
amount_sats: d.amount_sats,
|
||||||
details: d.source === "cashu" ? "Cashu redeem" : "Lightning",
|
details: d.source === "cashu" ? "Cashu redeem" : "Lightning",
|
||||||
}));
|
}));
|
||||||
const merged = [...payouts, ...deposits].sort((a, b) => b.at - a.at);
|
const sponsorPayments = (stats.recentSponsorPayments ?? []).map((s) => ({
|
||||||
|
at: Number(s.created_at) || 0,
|
||||||
|
direction: "in" as TxDirection,
|
||||||
|
type: "sponsor" as TxType,
|
||||||
|
amount_sats: s.amount_sats,
|
||||||
|
details: s.title || "Sponsor Ad",
|
||||||
|
}));
|
||||||
|
const merged = [...payouts, ...deposits, ...sponsorPayments].sort((a, b) => b.at - a.at);
|
||||||
return merged.slice(0, 50);
|
return merged.slice(0, 50);
|
||||||
}, [stats]);
|
}, [stats]);
|
||||||
|
|
||||||
@@ -139,6 +146,7 @@ export function TransactionsPage() {
|
|||||||
<option value="all">All</option>
|
<option value="all">All</option>
|
||||||
<option value="lightning">Lightning</option>
|
<option value="lightning">Lightning</option>
|
||||||
<option value="cashu">Cashu</option>
|
<option value="cashu">Cashu</option>
|
||||||
|
<option value="sponsor">Sponsor</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="tx-filter-group">
|
<div className="tx-filter-group">
|
||||||
@@ -224,7 +232,7 @@ export function TransactionsPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="tx-td-type">
|
<span className="tx-td-type">
|
||||||
<span className={`tx-badge tx-badge--type tx-badge--${tx.type}`}>
|
<span className={`tx-badge tx-badge--type tx-badge--${tx.type}`}>
|
||||||
{tx.type === "cashu" ? "Cashu" : "Lightning"}
|
{tx.type === "cashu" ? "Cashu" : tx.type === "sponsor" ? "Sponsor" : "Lightning"}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="tx-td-amount">{n(displaySats(tx))} sats</span>
|
<span className="tx-td-amount">{n(displaySats(tx))} sats</span>
|
||||||
@@ -254,7 +262,7 @@ export function TransactionsPage() {
|
|||||||
{tx.direction === "in" ? "In" : "Out"}
|
{tx.direction === "in" ? "In" : "Out"}
|
||||||
</span>
|
</span>
|
||||||
<span className={`tx-badge tx-badge--type tx-badge--${tx.type}`}>
|
<span className={`tx-badge tx-badge--type tx-badge--${tx.type}`}>
|
||||||
{tx.type === "cashu" ? "Cashu" : "Lightning"}
|
{tx.type === "cashu" ? "Cashu" : tx.type === "sponsor" ? "Sponsor" : "Lightning"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="tx-mobile-row3">
|
<div className="tx-mobile-row3">
|
||||||
|
|||||||
@@ -102,12 +102,30 @@ body {
|
|||||||
background: rgba(249, 115, 22, 0.1);
|
background: rgba(249, 115, 22, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hamburger: hidden on desktop */
|
||||||
|
.header-hamburger {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Header layout: right side groups nav + user */
|
/* Header layout: right side groups nav + user */
|
||||||
.site-header-right {
|
.site-header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
.header-login-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.header-login-btn:hover { opacity: 0.9; }
|
||||||
|
|
||||||
.header-user {
|
.header-user {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
@@ -173,6 +191,7 @@ body {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
animation: menu-in 0.15s ease-out;
|
animation: menu-in 0.15s ease-out;
|
||||||
@@ -216,6 +235,15 @@ body {
|
|||||||
}
|
}
|
||||||
.header-user-menu-item:hover {
|
.header-user-menu-item:hover {
|
||||||
background: var(--bg-card-hover);
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
a.header-user-menu-item {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
a.header-user-menu-item:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
button.header-user-menu-item:hover {
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +412,26 @@ body {
|
|||||||
.container--transactions .main--full { max-width: none; }
|
.container--transactions .main--full { max-width: none; }
|
||||||
.container--single { justify-content: center; }
|
.container--single { justify-content: center; }
|
||||||
|
|
||||||
|
/* Sponsors page: full-width route, no container constraint */
|
||||||
|
.sponsors-route {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-lg) var(--space-md);
|
||||||
|
padding-bottom: max(var(--space-xl), env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sponsors-route {
|
||||||
|
padding: var(--space-xl) var(--space-lg);
|
||||||
|
padding-bottom: max(var(--space-xxl), env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.sponsors-route {
|
||||||
|
padding: var(--space-md) var(--space-sm);
|
||||||
|
padding-bottom: max(var(--space-lg), env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Site footer */
|
/* Site footer */
|
||||||
.site-footer {
|
.site-footer {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
@@ -576,6 +624,10 @@ body {
|
|||||||
background: rgba(168, 85, 247, 0.15);
|
background: rgba(168, 85, 247, 0.15);
|
||||||
color: #a855f7;
|
color: #a855f7;
|
||||||
}
|
}
|
||||||
|
.tx-badge--type.tx-badge--sponsor {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile: stacked cards (visible only on mobile) */
|
/* Mobile: stacked cards (visible only on mobile) */
|
||||||
.tx-cards-mobile {
|
.tx-cards-mobile {
|
||||||
@@ -953,7 +1005,7 @@ h1 {
|
|||||||
/* Toast */
|
/* Toast */
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 24px;
|
bottom: max(24px, env(safe-area-inset-bottom));
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
@@ -1048,6 +1100,12 @@ h1 {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
animation: modal-scale-in 0.25s ease-out;
|
animation: modal-scale-in 0.25s ease-out;
|
||||||
}
|
}
|
||||||
|
.modal--sponsor {
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
.modal--sponsor .modal-body {
|
||||||
|
padding: 28px 24px;
|
||||||
|
}
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2374,6 +2432,127 @@ h1 {
|
|||||||
.main { order: 1; min-width: unset; }
|
.main { order: 1; min-width: unset; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hamburger: visible on mobile, drawer styles */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.header-hamburger {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.header-hamburger:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
}
|
||||||
|
.header-hamburger-bar {
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 2px;
|
||||||
|
background: currentColor;
|
||||||
|
border-radius: 1px;
|
||||||
|
transition: transform 0.2s, opacity 0.2s;
|
||||||
|
}
|
||||||
|
.site-header--drawer-open .header-hamburger-bar:nth-child(1) {
|
||||||
|
transform: translateY(7px) rotate(45deg);
|
||||||
|
}
|
||||||
|
.site-header--drawer-open .header-hamburger-bar:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
.site-header--drawer-open .header-hamburger-bar:nth-child(3) {
|
||||||
|
transform: translateY(-7px) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header-right {
|
||||||
|
position: fixed;
|
||||||
|
top: 56px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
transform: translateY(-100%);
|
||||||
|
visibility: hidden;
|
||||||
|
transition: transform 0.25s ease, visibility 0.25s;
|
||||||
|
}
|
||||||
|
.site-header--drawer-open .site-header-right {
|
||||||
|
transform: translateY(0);
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
.site-nav {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.site-nav-link {
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.header-login-btn {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.header-user {
|
||||||
|
padding-left: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
.header-user-trigger {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 12px 0;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.site-header-right .header-user-name {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
.header-user-chevron {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.header-user-menu {
|
||||||
|
position: static;
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
min-width: unset;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-drawer-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 56px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 99;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.header-drawer-overlay {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.site-header { padding: 0 16px; }
|
.site-header { padding: 0 16px; }
|
||||||
.site-header-inner { height: 52px; }
|
.site-header-inner { height: 52px; }
|
||||||
@@ -2553,18 +2732,18 @@ h1 {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-modal-overlay {
|
.modal-overlay:has(.modal--login) {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-modal {
|
.modal.modal--login {
|
||||||
max-height: 85vh;
|
max-height: 85vh;
|
||||||
border-radius: 12px 12px 0 0;
|
border-radius: 12px 12px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-modal-header,
|
.modal.modal--login .modal-header,
|
||||||
.login-modal-body {
|
.modal.modal--login .modal-body {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2572,6 +2751,15 @@ h1 {
|
|||||||
min-height: 48px;
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sponsor-form-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.sponsor-form-actions .sponsor-form-btn {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.claim-wizard-profile-card {
|
.claim-wizard-profile-card {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -2602,6 +2790,16 @@ h1 {
|
|||||||
.claim-wizard-quote-amount-value {
|
.claim-wizard-quote-amount-value {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sponsors-page-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.sponsors-page-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.sponsors-pricing {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rules summary in ConnectStep */
|
/* Rules summary in ConnectStep */
|
||||||
@@ -2714,3 +2912,442 @@ h1 {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sponsor components */
|
||||||
|
.sponsor-time-slider {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.sponsor-time-slider-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.sponsor-time-slider-label strong { color: var(--text); }
|
||||||
|
.sponsor-time-slider-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.sponsor-time-slider-input::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sponsor-time-slider-input::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.sponsor-time-slider-marks {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.sponsor-time-slider-mark {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.sponsor-time-slider-mark:hover { color: var(--text); }
|
||||||
|
.sponsor-time-slider-mark.active {
|
||||||
|
background: rgba(249, 115, 22, 0.2);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
.sponsor-card:hover {
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.sponsor-card-link {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.sponsor-card-image-wrap {
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
min-height: 180px;
|
||||||
|
background: var(--bg);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.sponsor-card-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.sponsor-card-fallback {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.sponsor-card-fallback.hidden { display: none; }
|
||||||
|
.sponsor-card-fallback-icon { font-size: 3rem; }
|
||||||
|
.sponsor-card-fallback-domain { font-size: 15px; }
|
||||||
|
.sponsor-card-body { padding: 24px; }
|
||||||
|
.sponsor-card-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.sponsor-card-desc {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.sponsor-card-cta {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.sponsor-card-meta {
|
||||||
|
padding: 0 24px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.sponsor-card-days { font-weight: 500; }
|
||||||
|
|
||||||
|
.sponsor-form { display: flex; flex-direction: column; gap: 28px; }
|
||||||
|
.sponsor-form-row { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.sponsor-form-label { font-size: 14px; font-weight: 500; color: var(--text-muted); }
|
||||||
|
.sponsor-form-label .required { color: var(--error); }
|
||||||
|
.sponsor-form-label .optional { font-weight: 400; color: var(--text-soft); }
|
||||||
|
.sponsor-form-input,
|
||||||
|
.sponsor-form-textarea {
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.sponsor-form-textarea { resize: vertical; min-height: 80px; }
|
||||||
|
.sponsor-extend-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-form-price {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-card-hover);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.sponsor-form-price-label { font-size: 14px; color: var(--text-muted); }
|
||||||
|
.sponsor-form-price-value { font-size: 1.25rem; color: var(--accent); }
|
||||||
|
.sponsor-form-error { font-size: 14px; color: var(--error); }
|
||||||
|
.sponsor-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.sponsor-form-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.sponsor-form-btn.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.sponsor-form-btn.primary:hover:not(:disabled) { opacity: 0.9; }
|
||||||
|
.sponsor-form-btn.primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.sponsor-form-btn.secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.sponsor-form-btn.secondary:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.sponsor-invoice { display: flex; flex-direction: column; align-items: center; gap: 28px; }
|
||||||
|
.sponsor-invoice-desc { font-size: 14px; color: var(--text-muted); text-align: center; }
|
||||||
|
.sponsor-invoice-qr {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 200px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.sponsor-invoice-qr img {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 200px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.sponsor-invoice-copy {
|
||||||
|
padding: 10px 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.sponsor-invoice-copy:hover { opacity: 0.9; }
|
||||||
|
.sponsor-invoice-note { font-size: 12px; color: var(--text-soft); }
|
||||||
|
|
||||||
|
.sponsors-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.sponsors-grid { grid-template-columns: repeat(2, 1fr); gap: 28px; }
|
||||||
|
}
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.sponsors-grid { grid-template-columns: repeat(3, 1fr); gap: 32px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.homepage-sponsors {
|
||||||
|
padding: var(--space-lg) var(--space-md);
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.homepage-sponsors-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.homepage-sponsors-link {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.homepage-sponsors-link a { color: var(--accent); }
|
||||||
|
.homepage-sponsors-link a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.sponsors-page {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.sponsors-page-header {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.sponsors-page-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.sponsors-page-subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.sponsors-pricing {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.sponsors-pricing h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.sponsors-pricing p {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.sponsors-cta-wrap {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
.sponsors-cta-btn {
|
||||||
|
padding: 14px 28px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.sponsors-cta-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.sponsors-section h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.sponsors-loading,
|
||||||
|
.sponsors-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-ads-page {
|
||||||
|
padding: var(--space-md);
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.my-ads-header { margin-bottom: 24px; }
|
||||||
|
.my-ads-header h1 { font-size: 1.5rem; margin-bottom: 8px; }
|
||||||
|
.my-ads-header p { color: var(--text-muted); font-size: 14px; }
|
||||||
|
.my-ads-loading, .my-ads-error { margin-bottom: 16px; }
|
||||||
|
.my-ads-error { color: var(--error); }
|
||||||
|
.my-ads-empty { padding: 32px; text-align: center; color: var(--text-muted); }
|
||||||
|
.my-ads-create-link { color: var(--accent); }
|
||||||
|
.my-ads-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.my-ads-table-wrap {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-ads-cards-mobile {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.my-ads-cards-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-ads-mobile-card {
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.my-ads-mobile-card:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.my-ads-mobile-row1 {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.my-ads-mobile-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.my-ads-mobile-row2 {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.my-ads-mobile-actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.my-ads-mobile-action-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.my-ads-mobile-action-link:hover {
|
||||||
|
background: rgba(249, 115, 22, 0.1);
|
||||||
|
}
|
||||||
|
.my-ads-mobile-action-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-ads-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.my-ads-table th, .my-ads-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.my-ads-table th { font-size: 12px; color: var(--text-soft); text-transform: uppercase; }
|
||||||
|
.my-ads-status { font-size: 12px; padding: 4px 8px; border-radius: 4px; }
|
||||||
|
.my-ads-status--active { background: rgba(34, 197, 94, 0.2); color: var(--accent-soft); }
|
||||||
|
.my-ads-status--pending_payment { background: rgba(249, 115, 22, 0.2); color: var(--accent); }
|
||||||
|
.my-ads-status--expired { background: var(--bg-card-hover); color: var(--text-muted); }
|
||||||
|
.my-ads-table a { color: var(--accent); }
|
||||||
|
.my-ads-pay-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.my-ads-pay-btn:hover:not(:disabled) {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
.my-ads-pay-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ export default defineConfig(({ mode }) => {
|
|||||||
"/config": { target: backendTarget, changeOrigin: true },
|
"/config": { target: backendTarget, changeOrigin: true },
|
||||||
"/stats": { target: backendTarget, changeOrigin: true },
|
"/stats": { target: backendTarget, changeOrigin: true },
|
||||||
"/deposit": { target: backendTarget, changeOrigin: true },
|
"/deposit": { target: backendTarget, changeOrigin: true },
|
||||||
|
"/sponsor/": { target: backendTarget, changeOrigin: true },
|
||||||
|
"/health": { target: backendTarget, changeOrigin: true },
|
||||||
|
"/openapi.json": { target: backendTarget, changeOrigin: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user