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:
Michilis
2026-03-16 00:01:19 +00:00
parent ac9b8dc330
commit dc7007f708
30 changed files with 3123 additions and 68 deletions

View File

@@ -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

View File

@@ -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 {

View File

@@ -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,
};
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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
);
},
};
}

View File

@@ -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>;
}

View File

@@ -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
View 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;

View File

@@ -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({

View 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;

View 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;

View 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;
}
}

View File

@@ -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 };
}

View File

@@ -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

View File

@@ -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 />

View File

@@ -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`);
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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 }}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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">

View File

@@ -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;
}

View File

@@ -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 },
},
},
};