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_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 ?? "",
|
||||
depositLnurlp: process.env.DEPOSIT_LNURLP ?? "",
|
||||
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 {
|
||||
|
||||
@@ -2,7 +2,7 @@ import pg from "pg";
|
||||
import { readFileSync } from "fs";
|
||||
import { dirname, join } from "path";
|
||||
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));
|
||||
|
||||
@@ -49,7 +49,7 @@ export function createPgDb(connectionString: string): Db {
|
||||
expires_at: Number(r.expires_at),
|
||||
status: r.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
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"
|
||||
);
|
||||
} 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> {
|
||||
@@ -343,5 +377,212 @@ export function createPgDb(connectionString: string): Db {
|
||||
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_deposits_created_at ON deposits(created_at);
|
||||
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_deposits_created_at ON deposits(created_at);
|
||||
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 { dirname, join } from "path";
|
||||
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));
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
} 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> {
|
||||
@@ -283,5 +317,184 @@ export function createSqliteDb(path: string): Db {
|
||||
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 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 {
|
||||
id: number;
|
||||
created_at: number;
|
||||
@@ -98,4 +123,23 @@ export interface Db {
|
||||
hasDepositWithPaymentHash(paymentHash: string): Promise<boolean>;
|
||||
updateDepositCreatedAtIfMissing(paymentHash: string, createdAt: number): Promise<boolean>;
|
||||
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 claimRoutes from "./routes/claim.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
|
||||
|
||||
@@ -62,6 +65,9 @@ async function main() {
|
||||
);
|
||||
|
||||
app.use("/", publicRoutes);
|
||||
app.use("/", sponsorWebhookRoutes);
|
||||
app.use("/sponsor", sponsorRoutes);
|
||||
app.use("/admin", adminRoutes);
|
||||
app.use("/auth", authRoutes);
|
||||
app.use(
|
||||
"/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 now = Math.floor(Date.now() / 1000);
|
||||
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),
|
||||
db.getTotalPaidSats(),
|
||||
db.getTotalClaimsCount(),
|
||||
@@ -34,6 +34,7 @@ router.get("/stats", async (_req: Request, res: Response) => {
|
||||
db.getPaidSatsSince(dayStart),
|
||||
db.getRecentPayouts(20),
|
||||
db.getRecentDeposits(20),
|
||||
db.getRecentSponsorPayments(20),
|
||||
]);
|
||||
res.json({
|
||||
balanceSats: balance,
|
||||
@@ -44,6 +45,7 @@ router.get("/stats", async (_req: Request, res: Response) => {
|
||||
spentTodaySats: spentToday,
|
||||
recentPayouts: recent,
|
||||
recentDeposits,
|
||||
recentSponsorPayments,
|
||||
});
|
||||
} catch (e) {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
# Leave empty if frontend is served from same origin as API
|
||||
VITE_API_URL=http://localhost:3001
|
||||
# Backend API URL. For dev: leave empty to use Vite proxy (recommended).
|
||||
# For production or when frontend is on different origin: set full URL e.g. http://localhost:3001
|
||||
# VITE_API_URL=
|
||||
|
||||
# Nostr relays for fetching user profile metadata (comma-separated)
|
||||
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 { DepositSection } from "./components/DepositSection";
|
||||
import { TransactionsPage } from "./pages/TransactionsPage";
|
||||
import { SponsorsPage } from "./pages/SponsorsPage";
|
||||
import { MyAdsPage } from "./pages/MyAdsPage";
|
||||
import { SponsorsSection } from "./components/SponsorsSection";
|
||||
|
||||
const FaucetSvg = () => (
|
||||
<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 [statsRefetchTrigger, setStatsRefetchTrigger] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshAuth = useCallback(() => {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
if (!token) {
|
||||
setPubkey(null);
|
||||
setLoginMethod(null);
|
||||
return;
|
||||
}
|
||||
getAuthMe()
|
||||
.then((r) => {
|
||||
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) => {
|
||||
setPubkey(pk);
|
||||
setLoginMethod(pk ? (method ?? "nip98") : null);
|
||||
@@ -62,28 +76,19 @@ export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="app">
|
||||
<Header pubkey={pubkey} onLogout={handleLogout} />
|
||||
<Header pubkey={pubkey} onLogout={handleLogout} onLoginSuccess={handlePubkeyChange} />
|
||||
<div className="topbar" />
|
||||
<div className="app-body">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<>
|
||||
<div className="container">
|
||||
<aside className="sidebar sidebar-left">
|
||||
<div className="funding-panel">
|
||||
<DepositSection />
|
||||
</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>
|
||||
<main className="main">
|
||||
<div className="header">
|
||||
@@ -96,6 +101,8 @@ export default function App() {
|
||||
<StatsSection refetchTrigger={statsRefetchTrigger} />
|
||||
</aside>
|
||||
</div>
|
||||
<SponsorsSection />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -108,6 +115,24 @@ export default function App() {
|
||||
</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>
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface Stats {
|
||||
spentTodaySats: number;
|
||||
recentPayouts: { pubkey_prefix: string; payout_sats: number; claimed_at: number }[];
|
||||
recentDeposits: { amount_sats: number; source: DepositSource; created_at: number }[];
|
||||
recentSponsorPayments?: { created_at: number; amount_sats: number; title: string }[];
|
||||
}
|
||||
|
||||
export interface DepositInfo {
|
||||
@@ -319,3 +320,120 @@ export async function postUserRefreshProfile(): Promise<UserProfile> {
|
||||
export function hasNostr(): boolean {
|
||||
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 { useNostrProfile } from "../hooks/useNostrProfile";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { LoginModal } from "./LoginModal";
|
||||
import type { LoginMethod } from "../api";
|
||||
|
||||
interface HeaderProps {
|
||||
pubkey: string | null;
|
||||
onLogout?: () => void;
|
||||
onLoginSuccess?: (pubkey: string, method: LoginMethod) => void;
|
||||
}
|
||||
|
||||
function truncatedNpub(pubkey: string): string {
|
||||
@@ -13,15 +16,31 @@ function truncatedNpub(pubkey: string): string {
|
||||
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 profile = useNostrProfile(pubkey);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const drawerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const displayName = profile?.display_name || profile?.name || (pubkey ? truncatedNpub(pubkey) : null);
|
||||
|
||||
const handleToggle = useCallback(() => setMenuOpen((o) => !o), []);
|
||||
const handleDrawerToggle = useCallback(() => setDrawerOpen((o) => !o), []);
|
||||
|
||||
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
closeDrawer();
|
||||
}, [location.pathname, closeDrawer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
@@ -41,33 +60,78 @@ export function Header({ pubkey, onLogout }: HeaderProps) {
|
||||
};
|
||||
}, [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 = () => {
|
||||
setMenuOpen(false);
|
||||
setDrawerOpen(false);
|
||||
onLogout?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="site-header">
|
||||
<header className={`site-header${drawerOpen ? " site-header--drawer-open" : ""}`}>
|
||||
<div className="site-header-inner">
|
||||
<Link to="/" className="site-logo">
|
||||
<span className="site-logo-text">Sats Faucet</span>
|
||||
</Link>
|
||||
<div className="site-header-right">
|
||||
<nav className="site-nav" aria-label="Main navigation">
|
||||
<Link
|
||||
to="/"
|
||||
className={location.pathname === "/" ? "site-nav-link active" : "site-nav-link"}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="header-hamburger"
|
||||
onClick={handleDrawerToggle}
|
||||
aria-expanded={drawerOpen}
|
||||
aria-controls="header-drawer"
|
||||
aria-label={drawerOpen ? "Close menu" : "Open menu"}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<span className="header-hamburger-bar" />
|
||||
<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
|
||||
to="/transactions"
|
||||
className={location.pathname === "/transactions" ? "site-nav-link active" : "site-nav-link"}
|
||||
key={to}
|
||||
to={to}
|
||||
className={location.pathname === to ? "site-nav-link active" : "site-nav-link"}
|
||||
onClick={closeDrawer}
|
||||
>
|
||||
Transactions
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
{pubkey && (
|
||||
{!pubkey ? (
|
||||
<button
|
||||
type="button"
|
||||
className="header-login-btn"
|
||||
onClick={() => {
|
||||
setLoginModalOpen(true);
|
||||
closeDrawer();
|
||||
}}
|
||||
>
|
||||
Login with Nostr
|
||||
</button>
|
||||
) : (
|
||||
<div className="header-user" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -100,6 +164,12 @@ export function Header({ pubkey, onLogout }: HeaderProps) {
|
||||
<span className="header-user-menu-npub">{truncatedNpub(pubkey)}</span>
|
||||
</div>
|
||||
<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}>
|
||||
<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" />
|
||||
@@ -114,6 +184,22 @@ export function Header({ pubkey, onLogout }: HeaderProps) {
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
|
||||
setToken(token);
|
||||
postUserRefreshProfile().catch(() => {});
|
||||
onSuccess(pubkey, method ?? "nip98");
|
||||
window.dispatchEvent(new CustomEvent("auth-changed"));
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
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 (
|
||||
<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">
|
||||
{(["extension", "remote", "nsec"] as const).map((t) => (
|
||||
<button
|
||||
|
||||
@@ -8,6 +8,8 @@ interface ModalProps {
|
||||
children: React.ReactNode;
|
||||
/** If true, do not close on overlay click (e.g. when loading). */
|
||||
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\"])";
|
||||
@@ -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 reduceMotion = useReducedMotion();
|
||||
|
||||
@@ -90,7 +92,7 @@ export function Modal({ open, onClose, title, children, preventClose }: ModalPro
|
||||
>
|
||||
<motion.div
|
||||
ref={modalRef}
|
||||
className="modal"
|
||||
className={`modal${variant ? ` modal--${variant}` : ""}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
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";
|
||||
|
||||
type TxDirection = "in" | "out";
|
||||
type TxType = "lightning" | "cashu";
|
||||
type TxType = "lightning" | "cashu" | "sponsor";
|
||||
|
||||
interface UnifiedTx {
|
||||
at: number;
|
||||
@@ -18,7 +18,7 @@ function formatSource(s: DepositSource): TxType {
|
||||
}
|
||||
|
||||
type DirectionFilter = "all" | "in" | "out";
|
||||
type TypeFilter = "all" | "lightning" | "cashu";
|
||||
type TypeFilter = "all" | "lightning" | "cashu" | "sponsor";
|
||||
type SortOrder = "newest" | "oldest";
|
||||
|
||||
export function TransactionsPage() {
|
||||
@@ -83,7 +83,14 @@ export function TransactionsPage() {
|
||||
amount_sats: d.amount_sats,
|
||||
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);
|
||||
}, [stats]);
|
||||
|
||||
@@ -139,6 +146,7 @@ export function TransactionsPage() {
|
||||
<option value="all">All</option>
|
||||
<option value="lightning">Lightning</option>
|
||||
<option value="cashu">Cashu</option>
|
||||
<option value="sponsor">Sponsor</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="tx-filter-group">
|
||||
@@ -224,7 +232,7 @@ export function TransactionsPage() {
|
||||
</span>
|
||||
<span className="tx-td-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 className="tx-td-amount">{n(displaySats(tx))} sats</span>
|
||||
@@ -254,7 +262,7 @@ export function TransactionsPage() {
|
||||
{tx.direction === "in" ? "In" : "Out"}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
<div className="tx-mobile-row3">
|
||||
|
||||
@@ -102,12 +102,30 @@ body {
|
||||
background: rgba(249, 115, 22, 0.1);
|
||||
}
|
||||
|
||||
/* Hamburger: hidden on desktop */
|
||||
.header-hamburger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Header layout: right side groups nav + user */
|
||||
.site-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
position: relative;
|
||||
padding-left: 16px;
|
||||
@@ -173,6 +191,7 @@ body {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||
min-width: 200px;
|
||||
max-width: calc(100vw - 32px);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
animation: menu-in 0.15s ease-out;
|
||||
@@ -216,6 +235,15 @@ body {
|
||||
}
|
||||
.header-user-menu-item: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);
|
||||
}
|
||||
|
||||
@@ -384,6 +412,26 @@ body {
|
||||
.container--transactions .main--full { max-width: none; }
|
||||
.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 {
|
||||
margin-top: auto;
|
||||
@@ -576,6 +624,10 @@ body {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
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) */
|
||||
.tx-cards-mobile {
|
||||
@@ -953,7 +1005,7 @@ h1 {
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
bottom: max(24px, env(safe-area-inset-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 12px 24px;
|
||||
@@ -1048,6 +1100,12 @@ h1 {
|
||||
overflow: auto;
|
||||
animation: modal-scale-in 0.25s ease-out;
|
||||
}
|
||||
.modal--sponsor {
|
||||
max-width: 480px;
|
||||
}
|
||||
.modal--sponsor .modal-body {
|
||||
padding: 28px 24px;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2374,6 +2432,127 @@ h1 {
|
||||
.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) {
|
||||
.site-header { padding: 0 16px; }
|
||||
.site-header-inner { height: 52px; }
|
||||
@@ -2553,18 +2732,18 @@ h1 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-modal-overlay {
|
||||
.modal-overlay:has(.modal--login) {
|
||||
padding: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.login-modal {
|
||||
.modal.modal--login {
|
||||
max-height: 85vh;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.login-modal-header,
|
||||
.login-modal-body {
|
||||
.modal.modal--login .modal-header,
|
||||
.modal.modal--login .modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@@ -2572,6 +2751,15 @@ h1 {
|
||||
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 {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
@@ -2602,6 +2790,16 @@ h1 {
|
||||
.claim-wizard-quote-amount-value {
|
||||
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 */
|
||||
@@ -2714,3 +2912,442 @@ h1 {
|
||||
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 },
|
||||
"/stats": { 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