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:
@@ -1,6 +1,6 @@
|
||||
# Backend API URL (required in dev when frontend runs on different port)
|
||||
# Leave empty if frontend is served from same origin as API
|
||||
VITE_API_URL=http://localhost:3001
|
||||
# Backend API URL. For dev: leave empty to use Vite proxy (recommended).
|
||||
# For production or when frontend is on different origin: set full URL e.g. http://localhost:3001
|
||||
# VITE_API_URL=
|
||||
|
||||
# Nostr relays for fetching user profile metadata (comma-separated)
|
||||
VITE_NOSTR_RELAYS=wss://relay.damus.io,wss://relay.nostr.band,wss://nos.lol
|
||||
|
||||
@@ -7,6 +7,9 @@ import { ClaimWizard } from "./components/ClaimWizard";
|
||||
import { StatsSection } from "./components/StatsSection";
|
||||
import { DepositSection } from "./components/DepositSection";
|
||||
import { TransactionsPage } from "./pages/TransactionsPage";
|
||||
import { SponsorsPage } from "./pages/SponsorsPage";
|
||||
import { MyAdsPage } from "./pages/MyAdsPage";
|
||||
import { SponsorsSection } from "./components/SponsorsSection";
|
||||
|
||||
const FaucetSvg = () => (
|
||||
<svg className="faucet-svg" viewBox="0 0 100 140" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -29,9 +32,13 @@ export default function App() {
|
||||
const [loginMethod, setLoginMethod] = useState<LoginMethod | null>(null);
|
||||
const [statsRefetchTrigger, setStatsRefetchTrigger] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshAuth = useCallback(() => {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
if (!token) {
|
||||
setPubkey(null);
|
||||
setLoginMethod(null);
|
||||
return;
|
||||
}
|
||||
getAuthMe()
|
||||
.then((r) => {
|
||||
setPubkey(r.pubkey);
|
||||
@@ -44,6 +51,13 @@ export default function App() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAuth();
|
||||
const onAuthChanged = () => refreshAuth();
|
||||
window.addEventListener("auth-changed", onAuthChanged);
|
||||
return () => window.removeEventListener("auth-changed", onAuthChanged);
|
||||
}, [refreshAuth]);
|
||||
|
||||
const handlePubkeyChange = useCallback((pk: string | null, method?: LoginMethod) => {
|
||||
setPubkey(pk);
|
||||
setLoginMethod(pk ? (method ?? "nip98") : null);
|
||||
@@ -62,40 +76,33 @@ 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">
|
||||
<FaucetSvg />
|
||||
<h1>Sats Faucet</h1>
|
||||
</div>
|
||||
<ClaimWizard pubkey={pubkey} loginMethod={loginMethod} onPubkeyChange={handlePubkeyChange} onClaimSuccess={handleClaimSuccess} />
|
||||
</main>
|
||||
<aside className="sidebar sidebar-right">
|
||||
<StatsSection refetchTrigger={statsRefetchTrigger} />
|
||||
</aside>
|
||||
</div>
|
||||
<>
|
||||
<div className="container">
|
||||
<aside className="sidebar sidebar-left">
|
||||
<div className="funding-panel">
|
||||
<DepositSection />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main">
|
||||
<div className="header">
|
||||
<FaucetSvg />
|
||||
<h1>Sats Faucet</h1>
|
||||
</div>
|
||||
<ClaimWizard pubkey={pubkey} loginMethod={loginMethod} onPubkeyChange={handlePubkeyChange} onClaimSuccess={handleClaimSuccess} />
|
||||
</main>
|
||||
<aside className="sidebar sidebar-right">
|
||||
<StatsSection refetchTrigger={statsRefetchTrigger} />
|
||||
</aside>
|
||||
</div>
|
||||
<SponsorsSection />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -108,6 +115,24 @@ export default function App() {
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/sponsors"
|
||||
element={
|
||||
<div className="sponsors-route">
|
||||
<SponsorsPage />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/my-ads"
|
||||
element={
|
||||
<div className="container container--single">
|
||||
<main className="main main--full">
|
||||
<MyAdsPage />
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface Stats {
|
||||
spentTodaySats: number;
|
||||
recentPayouts: { pubkey_prefix: string; payout_sats: number; claimed_at: number }[];
|
||||
recentDeposits: { amount_sats: number; source: DepositSource; created_at: number }[];
|
||||
recentSponsorPayments?: { created_at: number; amount_sats: number; title: string }[];
|
||||
}
|
||||
|
||||
export interface DepositInfo {
|
||||
@@ -319,3 +320,120 @@ export async function postUserRefreshProfile(): Promise<UserProfile> {
|
||||
export function hasNostr(): boolean {
|
||||
return Boolean(typeof window !== "undefined" && window.nostr);
|
||||
}
|
||||
|
||||
// Sponsor API
|
||||
export interface SponsorHomepageItem {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
image_url: string | null;
|
||||
link_url: string;
|
||||
category: string | null;
|
||||
expires_at: number | null;
|
||||
views: number;
|
||||
clicks: number;
|
||||
}
|
||||
|
||||
export interface SponsorListItem extends SponsorHomepageItem {}
|
||||
|
||||
export interface SponsorMyAd {
|
||||
id: number;
|
||||
npub: string;
|
||||
title: string;
|
||||
description: string;
|
||||
image_url: string | null;
|
||||
link_url: string;
|
||||
category: string | null;
|
||||
lightning_address: string | null;
|
||||
status: string;
|
||||
created_at: number;
|
||||
activated_at: number | null;
|
||||
expires_at: number | null;
|
||||
price_sats: number;
|
||||
duration_days: number;
|
||||
views: number;
|
||||
clicks: number;
|
||||
payment_hash?: string | null;
|
||||
payment_request?: string | null;
|
||||
}
|
||||
|
||||
export interface SponsorExtendResult {
|
||||
sponsor_id: number;
|
||||
payment_hash: string;
|
||||
payment_request: string;
|
||||
price_sats: number;
|
||||
duration_days: number;
|
||||
new_expires_at: number;
|
||||
}
|
||||
|
||||
export interface SponsorCreateResult {
|
||||
id: number;
|
||||
payment_hash: string;
|
||||
payment_request: string;
|
||||
price_sats: number;
|
||||
duration_days: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export async function getSponsorHomepage(): Promise<SponsorHomepageItem[]> {
|
||||
return request<SponsorHomepageItem[]>("/sponsor/homepage");
|
||||
}
|
||||
|
||||
export async function getSponsorList(): Promise<SponsorListItem[]> {
|
||||
return request<SponsorListItem[]>("/sponsor/list");
|
||||
}
|
||||
|
||||
export async function getSponsorMyAds(): Promise<SponsorMyAd[]> {
|
||||
if (getToken()) return requestWithBearer<SponsorMyAd[]>("GET", "/sponsor/my-ads");
|
||||
return requestWithNip98<SponsorMyAd[]>("GET", "/sponsor/my-ads");
|
||||
}
|
||||
|
||||
export async function postSponsorCreate(body: {
|
||||
title: string;
|
||||
description: string;
|
||||
link_url: string;
|
||||
image_url?: string;
|
||||
duration_days: number;
|
||||
}): Promise<SponsorCreateResult> {
|
||||
if (getToken()) return requestWithBearer<SponsorCreateResult>("POST", "/sponsor/create", body);
|
||||
return requestWithNip98<SponsorCreateResult>("POST", "/sponsor/create", body);
|
||||
}
|
||||
|
||||
export async function patchSponsorView(id: number): Promise<void> {
|
||||
await fetch(apiUrl(`/sponsor/${id}/view`), { method: "PATCH" });
|
||||
}
|
||||
|
||||
export function getSponsorClickUrl(id: number): string {
|
||||
return apiUrl(`/sponsor/click/${id}`);
|
||||
}
|
||||
|
||||
export async function getSponsorCheckPayment(paymentHash: string): Promise<{ paid: boolean }> {
|
||||
const data = await request<{ paid: boolean }>(`/sponsor/check-payment/${encodeURIComponent(paymentHash)}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function postSponsorExtend(
|
||||
sponsorId: number,
|
||||
durationDays: number
|
||||
): Promise<SponsorExtendResult> {
|
||||
if (getToken()) {
|
||||
return requestWithBearer<SponsorExtendResult>("PATCH", `/sponsor/${sponsorId}/extend`, {
|
||||
duration_days: durationDays,
|
||||
});
|
||||
}
|
||||
return requestWithNip98<SponsorExtendResult>("PATCH", `/sponsor/${sponsorId}/extend`, {
|
||||
duration_days: durationDays,
|
||||
});
|
||||
}
|
||||
|
||||
export async function postSponsorRegenerateInvoice(sponsorId: number): Promise<{
|
||||
payment_hash: string;
|
||||
payment_request: string;
|
||||
price_sats: number;
|
||||
duration_days: number;
|
||||
}> {
|
||||
if (getToken()) {
|
||||
return requestWithBearer("POST", `/sponsor/${sponsorId}/regenerate-invoice`);
|
||||
}
|
||||
return requestWithNip98("POST", `/sponsor/${sponsorId}/regenerate-invoice`);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@ import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useNostrProfile } from "../hooks/useNostrProfile";
|
||||
import { nip19 } from "nostr-tools";
|
||||
import { LoginModal } from "./LoginModal";
|
||||
import type { LoginMethod } from "../api";
|
||||
|
||||
interface HeaderProps {
|
||||
pubkey: string | null;
|
||||
onLogout?: () => void;
|
||||
onLoginSuccess?: (pubkey: string, method: LoginMethod) => void;
|
||||
}
|
||||
|
||||
function truncatedNpub(pubkey: string): string {
|
||||
@@ -13,15 +16,31 @@ function truncatedNpub(pubkey: string): string {
|
||||
return npub.slice(0, 12) + "..." + npub.slice(-4);
|
||||
}
|
||||
|
||||
export function Header({ pubkey, onLogout }: HeaderProps) {
|
||||
const navLinks = [
|
||||
{ to: "/", label: "Home" },
|
||||
{ to: "/transactions", label: "Transactions" },
|
||||
{ to: "/sponsors", label: "Sponsors" },
|
||||
] as const;
|
||||
|
||||
export function Header({ pubkey, onLogout, onLoginSuccess }: HeaderProps) {
|
||||
const location = useLocation();
|
||||
const profile = useNostrProfile(pubkey);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const drawerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const displayName = profile?.display_name || profile?.name || (pubkey ? truncatedNpub(pubkey) : null);
|
||||
|
||||
const handleToggle = useCallback(() => setMenuOpen((o) => !o), []);
|
||||
const handleDrawerToggle = useCallback(() => setDrawerOpen((o) => !o), []);
|
||||
|
||||
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
closeDrawer();
|
||||
}, [location.pathname, closeDrawer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
@@ -41,33 +60,78 @@ export function Header({ pubkey, onLogout }: HeaderProps) {
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawerOpen) return;
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (drawerRef.current && !drawerRef.current.contains(target) && !target.closest(".header-hamburger")) {
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
}
|
||||
function onEscape(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setDrawerOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
document.addEventListener("keydown", onEscape);
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onClickOutside);
|
||||
document.removeEventListener("keydown", onEscape);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [drawerOpen]);
|
||||
|
||||
const handleLogout = () => {
|
||||
setMenuOpen(false);
|
||||
setDrawerOpen(false);
|
||||
onLogout?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="site-header">
|
||||
<header className={`site-header${drawerOpen ? " site-header--drawer-open" : ""}`}>
|
||||
<div className="site-header-inner">
|
||||
<Link to="/" className="site-logo">
|
||||
<span className="site-logo-text">Sats Faucet</span>
|
||||
</Link>
|
||||
<div className="site-header-right">
|
||||
<nav className="site-nav" aria-label="Main navigation">
|
||||
<Link
|
||||
to="/"
|
||||
className={location.pathname === "/" ? "site-nav-link active" : "site-nav-link"}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/transactions"
|
||||
className={location.pathname === "/transactions" ? "site-nav-link active" : "site-nav-link"}
|
||||
>
|
||||
Transactions
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="header-hamburger"
|
||||
onClick={handleDrawerToggle}
|
||||
aria-expanded={drawerOpen}
|
||||
aria-controls="header-drawer"
|
||||
aria-label={drawerOpen ? "Close menu" : "Open menu"}
|
||||
>
|
||||
<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
|
||||
key={to}
|
||||
to={to}
|
||||
className={location.pathname === to ? "site-nav-link active" : "site-nav-link"}
|
||||
onClick={closeDrawer}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
{pubkey && (
|
||||
{!pubkey ? (
|
||||
<button
|
||||
type="button"
|
||||
className="header-login-btn"
|
||||
onClick={() => {
|
||||
setLoginModalOpen(true);
|
||||
closeDrawer();
|
||||
}}
|
||||
>
|
||||
Login with Nostr
|
||||
</button>
|
||||
) : (
|
||||
<div className="header-user" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -100,6 +164,12 @@ export function Header({ pubkey, onLogout }: HeaderProps) {
|
||||
<span className="header-user-menu-npub">{truncatedNpub(pubkey)}</span>
|
||||
</div>
|
||||
<div className="header-user-menu-divider" />
|
||||
<Link to="/my-ads" className="header-user-menu-item" role="menuitem" onClick={() => { setMenuOpen(false); closeDrawer(); }}>
|
||||
My Ads
|
||||
</Link>
|
||||
<Link to="/sponsors" className="header-user-menu-item" role="menuitem" onClick={() => { setMenuOpen(false); closeDrawer(); }}>
|
||||
Create Sponsor
|
||||
</Link>
|
||||
<button type="button" className="header-user-menu-item" role="menuitem" onClick={handleLogout}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
|
||||
@@ -114,6 +184,22 @@ export function Header({ pubkey, onLogout }: HeaderProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drawerOpen && (
|
||||
<div
|
||||
className="header-drawer-overlay"
|
||||
aria-hidden="true"
|
||||
onClick={closeDrawer}
|
||||
/>
|
||||
)}
|
||||
<LoginModal
|
||||
open={loginModalOpen}
|
||||
onClose={() => setLoginModalOpen(false)}
|
||||
onSuccess={(pk, method) => {
|
||||
setLoginModalOpen(false);
|
||||
onLoginSuccess?.(pk, method);
|
||||
}}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
|
||||
setToken(token);
|
||||
postUserRefreshProfile().catch(() => {});
|
||||
onSuccess(pubkey, method ?? "nip98");
|
||||
window.dispatchEvent(new CustomEvent("auth-changed"));
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : (e as ApiError)?.message ?? "Login failed";
|
||||
@@ -164,7 +165,7 @@ export function LoginModal({ open, onClose, onSuccess }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleClose} title="Log in with Nostr" preventClose={loading}>
|
||||
<Modal open={open} onClose={handleClose} title="Log in with Nostr" preventClose={loading} variant="login">
|
||||
<div className="login-modal-tabs">
|
||||
{(["extension", "remote", "nsec"] as const).map((t) => (
|
||||
<button
|
||||
|
||||
@@ -8,6 +8,8 @@ interface ModalProps {
|
||||
children: React.ReactNode;
|
||||
/** If true, do not close on overlay click (e.g. when loading). */
|
||||
preventClose?: boolean;
|
||||
/** Optional variant for styling (e.g. "sponsor" for larger modals). */
|
||||
variant?: string;
|
||||
}
|
||||
|
||||
const FOCUSABLE = "button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])";
|
||||
@@ -18,7 +20,7 @@ function getFocusables(container: HTMLElement): HTMLElement[] {
|
||||
);
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children, preventClose }: ModalProps) {
|
||||
export function Modal({ open, onClose, title, children, preventClose, variant }: ModalProps) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const reduceMotion = useReducedMotion();
|
||||
|
||||
@@ -90,7 +92,7 @@ export function Modal({ open, onClose, title, children, preventClose }: ModalPro
|
||||
>
|
||||
<motion.div
|
||||
ref={modalRef}
|
||||
className="modal"
|
||||
className={`modal${variant ? ` modal--${variant}` : ""}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
initial={reduceMotion ? false : { opacity: 0, scale: 0.98 }}
|
||||
|
||||
84
frontend/src/components/SponsorCard.tsx
Normal file
84
frontend/src/components/SponsorCard.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { getSponsorClickUrl, patchSponsorView, type SponsorHomepageItem } from "../api";
|
||||
|
||||
interface SponsorCardProps {
|
||||
sponsor: SponsorHomepageItem;
|
||||
}
|
||||
|
||||
function getDaysLeft(expiresAt: number | null): number {
|
||||
if (!expiresAt) return 0;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return Math.max(0, Math.ceil((expiresAt - now) / 86400));
|
||||
}
|
||||
|
||||
function extractDomain(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return "Sponsor";
|
||||
}
|
||||
}
|
||||
|
||||
export function SponsorCard({ sponsor }: SponsorCardProps) {
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const viewedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = cardRef.current;
|
||||
if (!el || viewedRef.current) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting && !viewedRef.current) {
|
||||
viewedRef.current = true;
|
||||
patchSponsorView(sponsor.id).catch(() => {});
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [sponsor.id]);
|
||||
|
||||
const daysLeft = getDaysLeft(sponsor.expires_at);
|
||||
const clickUrl = getSponsorClickUrl(sponsor.id);
|
||||
|
||||
return (
|
||||
<article ref={cardRef} className="sponsor-card">
|
||||
<a
|
||||
href={clickUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sponsor-card-link"
|
||||
aria-label={`Visit ${sponsor.title}`}
|
||||
>
|
||||
<div className="sponsor-card-image-wrap">
|
||||
{sponsor.image_url ? (
|
||||
<img
|
||||
src={sponsor.image_url}
|
||||
alt=""
|
||||
className="sponsor-card-image"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
(e.target as HTMLImageElement).nextElementSibling?.classList.remove("hidden");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className={`sponsor-card-fallback ${sponsor.image_url ? "hidden" : ""}`}>
|
||||
<span className="sponsor-card-fallback-icon">🔗</span>
|
||||
<span className="sponsor-card-fallback-domain">{extractDomain(sponsor.link_url)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sponsor-card-body">
|
||||
<h3 className="sponsor-card-title">{sponsor.title}</h3>
|
||||
<p className="sponsor-card-desc">{sponsor.description}</p>
|
||||
<span className="sponsor-card-cta">Visit sponsor</span>
|
||||
</div>
|
||||
</a>
|
||||
<div className="sponsor-card-meta">
|
||||
<span className="sponsor-card-days">{daysLeft} days left</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
145
frontend/src/components/SponsorForm.tsx
Normal file
145
frontend/src/components/SponsorForm.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { postSponsorCreate, type SponsorCreateResult } from "../api";
|
||||
import { SponsorTimeSlider } from "./SponsorTimeSlider";
|
||||
|
||||
const BASE_PRICE = 200;
|
||||
|
||||
function calculatePrice(days: number): number {
|
||||
let price = BASE_PRICE * days;
|
||||
if (days >= 180) price *= 0.7;
|
||||
else if (days >= 90) price *= 0.8;
|
||||
else if (days >= 30) price *= 0.9;
|
||||
return Math.round(price);
|
||||
}
|
||||
|
||||
interface SponsorFormProps {
|
||||
onSuccess?: (result: SponsorCreateResult) => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function SponsorForm({ onSuccess, onCancel }: SponsorFormProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [linkUrl, setLinkUrl] = useState("");
|
||||
const [imageUrl, setImageUrl] = useState("");
|
||||
const [durationDays, setDurationDays] = useState(30);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const priceSats = useMemo(() => calculatePrice(durationDays), [durationDays]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!title.trim()) {
|
||||
setError("Title is required");
|
||||
return;
|
||||
}
|
||||
if (!description.trim()) {
|
||||
setError("Description is required");
|
||||
return;
|
||||
}
|
||||
if (!linkUrl.trim() || !/^https?:\/\/.+/.test(linkUrl)) {
|
||||
setError("Valid URL (https://...) is required");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await postSponsorCreate({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
link_url: linkUrl.trim(),
|
||||
image_url: imageUrl.trim() || undefined,
|
||||
duration_days: durationDays,
|
||||
});
|
||||
onSuccess?.(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to create sponsor");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[title, description, linkUrl, imageUrl, durationDays, onSuccess]
|
||||
);
|
||||
|
||||
return (
|
||||
<form className="sponsor-form" onSubmit={handleSubmit}>
|
||||
<div className="sponsor-form-row">
|
||||
<label htmlFor="sponsor-title" className="sponsor-form-label">
|
||||
Title <span className="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="sponsor-title"
|
||||
type="text"
|
||||
className="sponsor-form-input"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Your project or product name"
|
||||
maxLength={100}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="sponsor-form-row">
|
||||
<label htmlFor="sponsor-desc" className="sponsor-form-label">
|
||||
Short description <span className="required">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="sponsor-desc"
|
||||
className="sponsor-form-textarea"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description (max 500 chars)"
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="sponsor-form-row">
|
||||
<label htmlFor="sponsor-link" className="sponsor-form-label">
|
||||
Destination URL <span className="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="sponsor-link"
|
||||
type="url"
|
||||
className="sponsor-form-input"
|
||||
value={linkUrl}
|
||||
onChange={(e) => setLinkUrl(e.target.value)}
|
||||
placeholder="https://..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="sponsor-form-row">
|
||||
<label htmlFor="sponsor-image" className="sponsor-form-label">
|
||||
Image URL <span className="optional">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="sponsor-image"
|
||||
type="url"
|
||||
className="sponsor-form-input"
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div className="sponsor-form-row">
|
||||
<SponsorTimeSlider value={durationDays} onChange={setDurationDays} />
|
||||
</div>
|
||||
<div className="sponsor-form-price">
|
||||
<span className="sponsor-form-price-label">Total:</span>
|
||||
<strong className="sponsor-form-price-value">{priceSats.toLocaleString()} sats</strong>
|
||||
</div>
|
||||
{error && <p className="sponsor-form-error" role="alert">{error}</p>}
|
||||
<div className="sponsor-form-actions">
|
||||
{onCancel && (
|
||||
<button type="button" className="sponsor-form-btn secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button type="submit" className="sponsor-form-btn primary" disabled={loading}>
|
||||
{loading ? "Creating…" : "Create & Pay"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
98
frontend/src/components/SponsorInvoiceModal.tsx
Normal file
98
frontend/src/components/SponsorInvoiceModal.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { Modal } from "./Modal";
|
||||
import { getSponsorCheckPayment } from "../api";
|
||||
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
const INVOICE_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
export interface PendingInvoice {
|
||||
payment_hash: string;
|
||||
payment_request: string;
|
||||
price_sats: number;
|
||||
duration_days: number;
|
||||
}
|
||||
|
||||
interface SponsorInvoiceModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
result: PendingInvoice | null;
|
||||
/** Called when payment is detected (e.g. to refresh sponsor list). */
|
||||
onPaid?: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function SponsorInvoiceModal({ open, onClose, result, onPaid, title }: SponsorInvoiceModalProps) {
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const onPaidRef = useRef(onPaid);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onPaidRef.current = onPaid;
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!result?.payment_request) {
|
||||
setQrDataUrl(null);
|
||||
return;
|
||||
}
|
||||
QRCode.toDataURL(result.payment_request, { width: 200 }).then(setQrDataUrl).catch(() => setQrDataUrl(null));
|
||||
}, [result?.payment_request]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !result?.payment_hash) return;
|
||||
const openedAt = Date.now();
|
||||
let cancelled = false;
|
||||
|
||||
const check = async () => {
|
||||
if (cancelled) return;
|
||||
if (Date.now() - openedAt > INVOICE_EXPIRY_MS) return;
|
||||
try {
|
||||
const { paid: isPaid } = await getSponsorCheckPayment(result!.payment_hash);
|
||||
if (cancelled) return;
|
||||
if (isPaid) {
|
||||
onPaidRef.current?.();
|
||||
onCloseRef.current();
|
||||
}
|
||||
} catch {
|
||||
// Ignore; will retry on next poll
|
||||
}
|
||||
};
|
||||
|
||||
check();
|
||||
const interval = setInterval(check, POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [open, result?.payment_hash]);
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!result?.payment_request) return;
|
||||
navigator.clipboard.writeText(result.payment_request).then(() => {
|
||||
// Could show a toast
|
||||
});
|
||||
};
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={title ?? "Pay to activate sponsor"} variant="sponsor">
|
||||
<div className="sponsor-invoice">
|
||||
<p className="sponsor-invoice-desc">
|
||||
Scan or copy the Lightning invoice to pay <strong>{result.price_sats.toLocaleString()} sats</strong>.
|
||||
Your sponsor will activate after payment.
|
||||
</p>
|
||||
{qrDataUrl && (
|
||||
<div className="sponsor-invoice-qr">
|
||||
<img src={qrDataUrl} alt="Lightning invoice QR" />
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="sponsor-invoice-copy" onClick={handleCopy}>
|
||||
Copy invoice
|
||||
</button>
|
||||
<p className="sponsor-invoice-note">
|
||||
Duration: {result.duration_days} days.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/SponsorTimeSlider.tsx
Normal file
66
frontend/src/components/SponsorTimeSlider.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
const SNAP_DAYS = [1, 3, 7, 14, 30, 60, 90, 180, 365];
|
||||
|
||||
function snapToNearest(value: number): number {
|
||||
let best = SNAP_DAYS[0];
|
||||
for (const d of SNAP_DAYS) {
|
||||
if (Math.abs(d - value) < Math.abs(best - value)) best = d;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
interface SponsorTimeSliderProps {
|
||||
value: number;
|
||||
onChange: (days: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export function SponsorTimeSlider({ value, onChange, min = 1, max = 365 }: SponsorTimeSliderProps) {
|
||||
const [internalValue, setInternalValue] = useState(value);
|
||||
const snapped = useMemo(() => snapToNearest(internalValue), [internalValue]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const raw = parseInt(e.target.value, 10);
|
||||
const clamped = Math.min(max, Math.max(min, Number.isFinite(raw) ? raw : min));
|
||||
setInternalValue(clamped);
|
||||
const days = snapToNearest(clamped);
|
||||
onChange(days);
|
||||
},
|
||||
[min, max, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="sponsor-time-slider">
|
||||
<label className="sponsor-time-slider-label">
|
||||
Display duration: <strong>{snapped} days</strong>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
className="sponsor-time-slider-input"
|
||||
min={min}
|
||||
max={max}
|
||||
value={internalValue}
|
||||
onChange={handleChange}
|
||||
aria-label="Sponsor display duration in days"
|
||||
/>
|
||||
<div className="sponsor-time-slider-marks">
|
||||
{SNAP_DAYS.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className={`sponsor-time-slider-mark ${snapped === d ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
setInternalValue(d);
|
||||
onChange(d);
|
||||
}}
|
||||
>
|
||||
{d}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
frontend/src/components/SponsorsSection.tsx
Normal file
31
frontend/src/components/SponsorsSection.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { getSponsorHomepage } from "../api";
|
||||
import { SponsorCard } from "./SponsorCard";
|
||||
import type { SponsorHomepageItem } from "../api";
|
||||
|
||||
export function SponsorsSection() {
|
||||
const [sponsors, setSponsors] = useState<SponsorHomepageItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSponsorHomepage()
|
||||
.then(setSponsors)
|
||||
.catch(() => setSponsors([]));
|
||||
}, []);
|
||||
|
||||
if (sponsors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="homepage-sponsors">
|
||||
<h2 className="homepage-sponsors-title">Sponsors</h2>
|
||||
<div className="sponsors-grid">
|
||||
{sponsors.map((s) => (
|
||||
<SponsorCard key={s.id} sponsor={s} />
|
||||
))}
|
||||
</div>
|
||||
<p className="homepage-sponsors-link">
|
||||
<Link to="/sponsors">View all sponsors · Sponsor the Faucet</Link>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
205
frontend/src/pages/MyAdsPage.tsx
Normal file
205
frontend/src/pages/MyAdsPage.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { getSponsorMyAds, getToken, postSponsorRegenerateInvoice } from "../api";
|
||||
import { SponsorInvoiceModal } from "../components/SponsorInvoiceModal";
|
||||
import type { SponsorMyAd } from "../api";
|
||||
|
||||
function formatStatus(s: string): string {
|
||||
return s.replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function getDaysLeft(expiresAt: number | null): number {
|
||||
if (!expiresAt) return 0;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return Math.max(0, Math.ceil((expiresAt - now) / 86400));
|
||||
}
|
||||
|
||||
export function MyAdsPage() {
|
||||
const [ads, setAds] = useState<SponsorMyAd[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
|
||||
const [pendingInvoice, setPendingInvoice] = useState<{
|
||||
payment_hash: string;
|
||||
payment_request: string;
|
||||
price_sats: number;
|
||||
duration_days: number;
|
||||
} | null>(null);
|
||||
const [invoiceLoading, setInvoiceLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "My Ads — Sats Faucet";
|
||||
return () => { document.title = "Sats Faucet — Free Bitcoin for Nostr Users"; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setError("Please log in to view your ads.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getSponsorMyAds()
|
||||
.then((list) => {
|
||||
if (!cancelled) setAds(list);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : "Failed to load");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const refreshAds = useCallback(() => {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
getSponsorMyAds()
|
||||
.then(setAds)
|
||||
.catch(() => setAds([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handlePayInvoice = useCallback(async (ad: SponsorMyAd) => {
|
||||
if (ad.payment_request && ad.payment_hash) {
|
||||
setPendingInvoice({
|
||||
payment_hash: ad.payment_hash,
|
||||
payment_request: ad.payment_request,
|
||||
price_sats: ad.price_sats,
|
||||
duration_days: ad.duration_days,
|
||||
});
|
||||
setInvoiceModalOpen(true);
|
||||
return;
|
||||
}
|
||||
setInvoiceLoading(true);
|
||||
try {
|
||||
const result = await postSponsorRegenerateInvoice(ad.id);
|
||||
setPendingInvoice(result);
|
||||
setInvoiceModalOpen(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load invoice");
|
||||
} finally {
|
||||
setInvoiceLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!getToken()) {
|
||||
return (
|
||||
<div className="my-ads-page">
|
||||
<h1>My Ads</h1>
|
||||
<p>Please <Link to="/">log in</Link> to view your sponsor ads.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-ads-page">
|
||||
<header className="my-ads-header">
|
||||
<h1>My Ads</h1>
|
||||
<p>Manage your sponsor placements.</p>
|
||||
</header>
|
||||
|
||||
{loading && <div className="my-ads-loading">Loading…</div>}
|
||||
{error && <p className="my-ads-error">{error}</p>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{ads.length === 0 ? (
|
||||
<div className="my-ads-empty">
|
||||
<p>You have no sponsor ads yet.</p>
|
||||
<Link to="/sponsors" className="my-ads-create-link">Create a sponsor</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop: table */}
|
||||
<div className="my-ads-table-wrap">
|
||||
<table className="my-ads-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
<th>Time left</th>
|
||||
<th>Views</th>
|
||||
<th>Clicks</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ads.map((ad) => (
|
||||
<tr key={ad.id}>
|
||||
<td>{ad.title}</td>
|
||||
<td><span className={`my-ads-status my-ads-status--${ad.status}`}>{formatStatus(ad.status)}</span></td>
|
||||
<td>{getDaysLeft(ad.expires_at)} days</td>
|
||||
<td>{ad.views}</td>
|
||||
<td>{ad.clicks}</td>
|
||||
<td>
|
||||
{ad.status === "active" || ad.status === "expired" ? (
|
||||
<Link to={`/sponsors?extend=${ad.id}`}>Extend</Link>
|
||||
) : ad.status === "pending_payment" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="my-ads-pay-btn"
|
||||
onClick={() => handlePayInvoice(ad)}
|
||||
disabled={invoiceLoading}
|
||||
>
|
||||
{invoiceLoading ? "Loading…" : "Pay invoice"}
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile: stacked cards */}
|
||||
<div className="my-ads-cards-mobile">
|
||||
{ads.map((ad) => (
|
||||
<article key={ad.id} className="my-ads-mobile-card">
|
||||
<div className="my-ads-mobile-row1">
|
||||
<h3 className="my-ads-mobile-title">{ad.title}</h3>
|
||||
<span className={`my-ads-status my-ads-status--${ad.status}`}>{formatStatus(ad.status)}</span>
|
||||
</div>
|
||||
<div className="my-ads-mobile-row2">
|
||||
<span className="my-ads-mobile-meta">{getDaysLeft(ad.expires_at)} days left</span>
|
||||
<span className="my-ads-mobile-meta">{ad.views} views · {ad.clicks} clicks</span>
|
||||
</div>
|
||||
<div className="my-ads-mobile-actions">
|
||||
{ad.status === "active" || ad.status === "expired" ? (
|
||||
<Link to={`/sponsors?extend=${ad.id}`} className="my-ads-mobile-action-link">Extend</Link>
|
||||
) : ad.status === "pending_payment" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="my-ads-mobile-action-link"
|
||||
onClick={() => handlePayInvoice(ad)}
|
||||
disabled={invoiceLoading}
|
||||
>
|
||||
{invoiceLoading ? "Loading…" : "Pay invoice"}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SponsorInvoiceModal
|
||||
open={invoiceModalOpen}
|
||||
onClose={() => {
|
||||
setInvoiceModalOpen(false);
|
||||
setPendingInvoice(null);
|
||||
}}
|
||||
result={pendingInvoice}
|
||||
onPaid={refreshAds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
231
frontend/src/pages/SponsorsPage.tsx
Normal file
231
frontend/src/pages/SponsorsPage.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { getSponsorList, getToken, postSponsorExtend } from "../api";
|
||||
import { SponsorCard } from "../components/SponsorCard";
|
||||
import { SponsorForm } from "../components/SponsorForm";
|
||||
import { SponsorInvoiceModal } from "../components/SponsorInvoiceModal";
|
||||
import { SponsorTimeSlider } from "../components/SponsorTimeSlider";
|
||||
import { LoginModal } from "../components/LoginModal";
|
||||
import { Modal } from "../components/Modal";
|
||||
import type { SponsorCreateResult, SponsorListItem } from "../api";
|
||||
|
||||
export function SponsorsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const extendId = searchParams.get("extend");
|
||||
const [sponsors, setSponsors] = useState<SponsorListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||
const [invoiceResult, setInvoiceResult] = useState<{
|
||||
payment_hash: string;
|
||||
payment_request: string;
|
||||
price_sats: number;
|
||||
duration_days: number;
|
||||
} | null>(null);
|
||||
const [invoiceModalOpen, setInvoiceModalOpen] = useState(false);
|
||||
const [extendModalOpen, setExtendModalOpen] = useState(false);
|
||||
const [extendDuration, setExtendDuration] = useState(30);
|
||||
const [extendLoading, setExtendLoading] = useState(false);
|
||||
const [extendError, setExtendError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Sponsors — Sats Faucet";
|
||||
return () => { document.title = "Sats Faucet — Free Bitcoin for Nostr Users"; };
|
||||
}, []);
|
||||
|
||||
const refreshSponsors = () => {
|
||||
setLoading(true);
|
||||
getSponsorList()
|
||||
.then(setSponsors)
|
||||
.catch(() => setSponsors([]))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getSponsorList()
|
||||
.then((list) => {
|
||||
if (!cancelled) setSponsors(list);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSponsors([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (extendId) {
|
||||
if (getToken()) {
|
||||
setExtendModalOpen(true);
|
||||
} else {
|
||||
setLoginModalOpen(true);
|
||||
}
|
||||
}
|
||||
}, [extendId]);
|
||||
|
||||
const handleExtendSubmit = async () => {
|
||||
if (!extendId) return;
|
||||
const id = parseInt(extendId, 10);
|
||||
if (!Number.isFinite(id) || id < 1) return;
|
||||
setExtendLoading(true);
|
||||
setExtendError(null);
|
||||
try {
|
||||
const result = await postSponsorExtend(id, extendDuration);
|
||||
setInvoiceResult({
|
||||
payment_hash: result.payment_hash,
|
||||
payment_request: result.payment_request,
|
||||
price_sats: result.price_sats,
|
||||
duration_days: result.duration_days,
|
||||
});
|
||||
setExtendModalOpen(false);
|
||||
setSearchParams({});
|
||||
setInvoiceModalOpen(true);
|
||||
} catch (e) {
|
||||
setExtendError(e instanceof Error ? e.message : "Failed to create extend invoice");
|
||||
} finally {
|
||||
setExtendLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeExtendModal = () => {
|
||||
setExtendModalOpen(false);
|
||||
setExtendError(null);
|
||||
setSearchParams({});
|
||||
};
|
||||
|
||||
const handleCreateSuccess = (result: SponsorCreateResult) => {
|
||||
setInvoiceResult({
|
||||
payment_hash: result.payment_hash,
|
||||
payment_request: result.payment_request,
|
||||
price_sats: result.price_sats,
|
||||
duration_days: result.duration_days,
|
||||
});
|
||||
setInvoiceModalOpen(true);
|
||||
setFormOpen(false);
|
||||
};
|
||||
|
||||
const handleSponsorCtaClick = () => {
|
||||
if (getToken()) {
|
||||
setFormOpen(true);
|
||||
} else {
|
||||
setLoginModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sponsors-page">
|
||||
<header className="sponsors-page-header">
|
||||
<h1 className="sponsors-page-title">Sponsors</h1>
|
||||
<p className="sponsors-page-subtitle">
|
||||
Support the faucet and get visibility. Sponsors fund payouts and appear on the homepage.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="sponsors-pricing">
|
||||
<h3>Pricing</h3>
|
||||
<p>Base: 200 sats/day. Discounts: 30+ days 10% off, 90+ days 20% off, 180+ days 30% off.</p>
|
||||
</div>
|
||||
|
||||
<div className="sponsors-cta-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="sponsors-cta-btn"
|
||||
onClick={handleSponsorCtaClick}
|
||||
>
|
||||
Sponsor the Faucet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section className="sponsors-section">
|
||||
<h2>Active Sponsors</h2>
|
||||
{loading ? (
|
||||
<div className="sponsors-loading">Loading…</div>
|
||||
) : sponsors.length === 0 ? (
|
||||
<div className="sponsors-empty">
|
||||
<p>No active sponsors yet. Be the first!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="sponsors-grid">
|
||||
{sponsors.map((s) => (
|
||||
<SponsorCard key={s.id} sponsor={s} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Modal open={formOpen} onClose={() => setFormOpen(false)} title="Create Sponsor" variant="sponsor">
|
||||
<SponsorForm
|
||||
onSuccess={handleCreateSuccess}
|
||||
onCancel={() => setFormOpen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<SponsorInvoiceModal
|
||||
open={invoiceModalOpen}
|
||||
onClose={() => {
|
||||
setInvoiceModalOpen(false);
|
||||
setInvoiceResult(null);
|
||||
}}
|
||||
result={invoiceResult}
|
||||
onPaid={refreshSponsors}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={extendModalOpen}
|
||||
onClose={closeExtendModal}
|
||||
title="Extend sponsor"
|
||||
variant="sponsor"
|
||||
>
|
||||
<div className="sponsor-extend-form">
|
||||
<p className="sponsor-extend-desc">
|
||||
Add more days to your sponsor placement. Select the duration and pay the invoice.
|
||||
</p>
|
||||
<div className="sponsor-form-row">
|
||||
<SponsorTimeSlider value={extendDuration} onChange={setExtendDuration} />
|
||||
</div>
|
||||
<div className="sponsor-form-price">
|
||||
<span className="sponsor-form-price-label">Total:</span>
|
||||
<strong className="sponsor-form-price-value">
|
||||
{Math.round(200 * extendDuration * (extendDuration >= 180 ? 0.7 : extendDuration >= 90 ? 0.8 : extendDuration >= 30 ? 0.9 : 1)).toLocaleString()} sats
|
||||
</strong>
|
||||
</div>
|
||||
{extendError && <p className="sponsor-form-error" role="alert">{extendError}</p>}
|
||||
<div className="sponsor-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="sponsor-form-btn secondary"
|
||||
onClick={closeExtendModal}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sponsor-form-btn primary"
|
||||
onClick={handleExtendSubmit}
|
||||
disabled={extendLoading}
|
||||
>
|
||||
{extendLoading ? "Creating…" : "Get invoice"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<LoginModal
|
||||
open={loginModalOpen}
|
||||
onClose={() => setLoginModalOpen(false)}
|
||||
onSuccess={() => {
|
||||
setLoginModalOpen(false);
|
||||
if (extendId) {
|
||||
setExtendModalOpen(true);
|
||||
} else {
|
||||
setFormOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||
import { getStats, type Stats, type DepositSource } from "../api";
|
||||
|
||||
type TxDirection = "in" | "out";
|
||||
type TxType = "lightning" | "cashu";
|
||||
type TxType = "lightning" | "cashu" | "sponsor";
|
||||
|
||||
interface UnifiedTx {
|
||||
at: number;
|
||||
@@ -18,7 +18,7 @@ function formatSource(s: DepositSource): TxType {
|
||||
}
|
||||
|
||||
type DirectionFilter = "all" | "in" | "out";
|
||||
type TypeFilter = "all" | "lightning" | "cashu";
|
||||
type TypeFilter = "all" | "lightning" | "cashu" | "sponsor";
|
||||
type SortOrder = "newest" | "oldest";
|
||||
|
||||
export function TransactionsPage() {
|
||||
@@ -83,7 +83,14 @@ export function TransactionsPage() {
|
||||
amount_sats: d.amount_sats,
|
||||
details: d.source === "cashu" ? "Cashu redeem" : "Lightning",
|
||||
}));
|
||||
const merged = [...payouts, ...deposits].sort((a, b) => b.at - a.at);
|
||||
const sponsorPayments = (stats.recentSponsorPayments ?? []).map((s) => ({
|
||||
at: Number(s.created_at) || 0,
|
||||
direction: "in" as TxDirection,
|
||||
type: "sponsor" as TxType,
|
||||
amount_sats: s.amount_sats,
|
||||
details: s.title || "Sponsor Ad",
|
||||
}));
|
||||
const merged = [...payouts, ...deposits, ...sponsorPayments].sort((a, b) => b.at - a.at);
|
||||
return merged.slice(0, 50);
|
||||
}, [stats]);
|
||||
|
||||
@@ -139,6 +146,7 @@ export function TransactionsPage() {
|
||||
<option value="all">All</option>
|
||||
<option value="lightning">Lightning</option>
|
||||
<option value="cashu">Cashu</option>
|
||||
<option value="sponsor">Sponsor</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="tx-filter-group">
|
||||
@@ -224,7 +232,7 @@ export function TransactionsPage() {
|
||||
</span>
|
||||
<span className="tx-td-type">
|
||||
<span className={`tx-badge tx-badge--type tx-badge--${tx.type}`}>
|
||||
{tx.type === "cashu" ? "Cashu" : "Lightning"}
|
||||
{tx.type === "cashu" ? "Cashu" : tx.type === "sponsor" ? "Sponsor" : "Lightning"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="tx-td-amount">{n(displaySats(tx))} sats</span>
|
||||
@@ -254,7 +262,7 @@ export function TransactionsPage() {
|
||||
{tx.direction === "in" ? "In" : "Out"}
|
||||
</span>
|
||||
<span className={`tx-badge tx-badge--type tx-badge--${tx.type}`}>
|
||||
{tx.type === "cashu" ? "Cashu" : "Lightning"}
|
||||
{tx.type === "cashu" ? "Cashu" : tx.type === "sponsor" ? "Sponsor" : "Lightning"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tx-mobile-row3">
|
||||
|
||||
@@ -102,12 +102,30 @@ body {
|
||||
background: rgba(249, 115, 22, 0.1);
|
||||
}
|
||||
|
||||
/* Hamburger: hidden on desktop */
|
||||
.header-hamburger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Header layout: right side groups nav + user */
|
||||
.site-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.header-login-btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.header-login-btn:hover { opacity: 0.9; }
|
||||
|
||||
.header-user {
|
||||
position: relative;
|
||||
padding-left: 16px;
|
||||
@@ -173,6 +191,7 @@ body {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||
min-width: 200px;
|
||||
max-width: calc(100vw - 32px);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
animation: menu-in 0.15s ease-out;
|
||||
@@ -216,6 +235,15 @@ body {
|
||||
}
|
||||
.header-user-menu-item:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
a.header-user-menu-item {
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
a.header-user-menu-item:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
button.header-user-menu-item:hover {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
@@ -384,6 +412,26 @@ body {
|
||||
.container--transactions .main--full { max-width: none; }
|
||||
.container--single { justify-content: center; }
|
||||
|
||||
/* Sponsors page: full-width route, no container constraint */
|
||||
.sponsors-route {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
padding-bottom: max(var(--space-xl), env(safe-area-inset-bottom));
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.sponsors-route {
|
||||
padding: var(--space-xl) var(--space-lg);
|
||||
padding-bottom: max(var(--space-xxl), env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.sponsors-route {
|
||||
padding: var(--space-md) var(--space-sm);
|
||||
padding-bottom: max(var(--space-lg), env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
/* Site footer */
|
||||
.site-footer {
|
||||
margin-top: auto;
|
||||
@@ -576,6 +624,10 @@ body {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: #a855f7;
|
||||
}
|
||||
.tx-badge--type.tx-badge--sponsor {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Mobile: stacked cards (visible only on mobile) */
|
||||
.tx-cards-mobile {
|
||||
@@ -953,7 +1005,7 @@ h1 {
|
||||
/* Toast */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
bottom: max(24px, env(safe-area-inset-bottom));
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 12px 24px;
|
||||
@@ -1048,6 +1100,12 @@ h1 {
|
||||
overflow: auto;
|
||||
animation: modal-scale-in 0.25s ease-out;
|
||||
}
|
||||
.modal--sponsor {
|
||||
max-width: 480px;
|
||||
}
|
||||
.modal--sponsor .modal-body {
|
||||
padding: 28px 24px;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2374,6 +2432,127 @@ h1 {
|
||||
.main { order: 1; min-width: unset; }
|
||||
}
|
||||
|
||||
/* Hamburger: visible on mobile, drawer styles */
|
||||
@media (max-width: 767px) {
|
||||
.header-hamburger {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.header-hamburger:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
.header-hamburger-bar {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
transition: transform 0.2s, opacity 0.2s;
|
||||
}
|
||||
.site-header--drawer-open .header-hamburger-bar:nth-child(1) {
|
||||
transform: translateY(7px) rotate(45deg);
|
||||
}
|
||||
.site-header--drawer-open .header-hamburger-bar:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
.site-header--drawer-open .header-hamburger-bar:nth-child(3) {
|
||||
transform: translateY(-7px) rotate(-45deg);
|
||||
}
|
||||
|
||||
.site-header-right {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 16px;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||
transform: translateY(-100%);
|
||||
visibility: hidden;
|
||||
transition: transform 0.25s ease, visibility 0.25s;
|
||||
}
|
||||
.site-header--drawer-open .site-header-right {
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
}
|
||||
.site-nav {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.site-nav-link {
|
||||
padding: 14px 16px;
|
||||
font-size: 16px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header-login-btn {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
font-size: 16px;
|
||||
}
|
||||
.header-user {
|
||||
padding-left: 0;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 16px;
|
||||
}
|
||||
.header-user-trigger {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
padding: 12px 0;
|
||||
min-height: 44px;
|
||||
}
|
||||
.site-header-right .header-user-name {
|
||||
display: inline;
|
||||
}
|
||||
.header-user-chevron {
|
||||
display: inline-block;
|
||||
margin-left: auto;
|
||||
}
|
||||
.header-user-menu {
|
||||
position: static;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
min-width: unset;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-drawer-overlay {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 99;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.header-drawer-overlay {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.site-header { padding: 0 16px; }
|
||||
.site-header-inner { height: 52px; }
|
||||
@@ -2553,18 +2732,18 @@ h1 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-modal-overlay {
|
||||
.modal-overlay:has(.modal--login) {
|
||||
padding: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.login-modal {
|
||||
.modal.modal--login {
|
||||
max-height: 85vh;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.login-modal-header,
|
||||
.login-modal-body {
|
||||
.modal.modal--login .modal-header,
|
||||
.modal.modal--login .modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@@ -2572,6 +2751,15 @@ h1 {
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.sponsor-form-actions {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.sponsor-form-actions .sponsor-form-btn {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.claim-wizard-profile-card {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
@@ -2602,6 +2790,16 @@ h1 {
|
||||
.claim-wizard-quote-amount-value {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.sponsors-page-title {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.sponsors-page-subtitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
.sponsors-pricing {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Rules summary in ConnectStep */
|
||||
@@ -2714,3 +2912,442 @@ h1 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sponsor components */
|
||||
.sponsor-time-slider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.sponsor-time-slider-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.sponsor-time-slider-label strong { color: var(--text); }
|
||||
.sponsor-time-slider-input {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.sponsor-time-slider-input::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.sponsor-time-slider-input::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
.sponsor-time-slider-marks {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.sponsor-time-slider-mark {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-card-hover);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.sponsor-time-slider-mark:hover { color: var(--text); }
|
||||
.sponsor-time-slider-mark.active {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sponsor-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-card);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.sponsor-card:hover {
|
||||
background: var(--bg-card-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.sponsor-card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.sponsor-card-image-wrap {
|
||||
aspect-ratio: 16/9;
|
||||
min-height: 180px;
|
||||
background: var(--bg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sponsor-card-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.sponsor-card-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: var(--bg-card-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.sponsor-card-fallback.hidden { display: none; }
|
||||
.sponsor-card-fallback-icon { font-size: 3rem; }
|
||||
.sponsor-card-fallback-domain { font-size: 15px; }
|
||||
.sponsor-card-body { padding: 24px; }
|
||||
.sponsor-card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
.sponsor-card-desc {
|
||||
font-size: 15px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.55;
|
||||
margin-bottom: 16px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sponsor-card-cta {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
}
|
||||
.sponsor-card-meta {
|
||||
padding: 0 24px 24px;
|
||||
font-size: 14px;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.sponsor-card-days { font-weight: 500; }
|
||||
|
||||
.sponsor-form { display: flex; flex-direction: column; gap: 28px; }
|
||||
.sponsor-form-row { display: flex; flex-direction: column; gap: 6px; }
|
||||
.sponsor-form-label { font-size: 14px; font-weight: 500; color: var(--text-muted); }
|
||||
.sponsor-form-label .required { color: var(--error); }
|
||||
.sponsor-form-label .optional { font-weight: 400; color: var(--text-soft); }
|
||||
.sponsor-form-input,
|
||||
.sponsor-form-textarea {
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
}
|
||||
.sponsor-form-textarea { resize: vertical; min-height: 80px; }
|
||||
.sponsor-extend-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sponsor-form-price {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--bg-card-hover);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.sponsor-form-price-label { font-size: 14px; color: var(--text-muted); }
|
||||
.sponsor-form-price-value { font-size: 1.25rem; color: var(--accent); }
|
||||
.sponsor-form-error { font-size: 14px; color: var(--error); }
|
||||
.sponsor-form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.sponsor-form-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.sponsor-form-btn.primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
.sponsor-form-btn.primary:hover:not(:disabled) { opacity: 0.9; }
|
||||
.sponsor-form-btn.primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.sponsor-form-btn.secondary {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.sponsor-form-btn.secondary:hover { color: var(--text); }
|
||||
|
||||
.sponsor-invoice { display: flex; flex-direction: column; align-items: center; gap: 28px; }
|
||||
.sponsor-invoice-desc { font-size: 14px; color: var(--text-muted); text-align: center; }
|
||||
.sponsor-invoice-qr {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.sponsor-invoice-qr img {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
height: auto;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.sponsor-invoice-copy {
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sponsor-invoice-copy:hover { opacity: 0.9; }
|
||||
.sponsor-invoice-note { font-size: 12px; color: var(--text-soft); }
|
||||
|
||||
.sponsors-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.sponsors-grid { grid-template-columns: repeat(2, 1fr); gap: 28px; }
|
||||
}
|
||||
@media (min-width: 900px) {
|
||||
.sponsors-grid { grid-template-columns: repeat(3, 1fr); gap: 32px; }
|
||||
}
|
||||
|
||||
.homepage-sponsors {
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.homepage-sponsors-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
margin-top: 0;
|
||||
}
|
||||
.homepage-sponsors-link {
|
||||
margin-top: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.homepage-sponsors-link a { color: var(--accent); }
|
||||
.homepage-sponsors-link a:hover { text-decoration: underline; }
|
||||
|
||||
.sponsors-page {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.sponsors-page-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.sponsors-page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
.sponsors-page-subtitle {
|
||||
font-size: 16px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.sponsors-pricing {
|
||||
margin-bottom: 28px;
|
||||
padding: 20px 24px;
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.sponsors-pricing h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text);
|
||||
}
|
||||
.sponsors-pricing p {
|
||||
font-size: 15px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.sponsors-cta-wrap {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.sponsors-cta-btn {
|
||||
padding: 14px 28px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.sponsors-cta-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.sponsors-section h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text);
|
||||
}
|
||||
.sponsors-loading,
|
||||
.sponsors-empty {
|
||||
color: var(--text-muted);
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.my-ads-page {
|
||||
padding: var(--space-md);
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.my-ads-header { margin-bottom: 24px; }
|
||||
.my-ads-header h1 { font-size: 1.5rem; margin-bottom: 8px; }
|
||||
.my-ads-header p { color: var(--text-muted); font-size: 14px; }
|
||||
.my-ads-loading, .my-ads-error { margin-bottom: 16px; }
|
||||
.my-ads-error { color: var(--error); }
|
||||
.my-ads-empty { padding: 32px; text-align: center; color: var(--text-muted); }
|
||||
.my-ads-create-link { color: var(--accent); }
|
||||
.my-ads-table-wrap {
|
||||
overflow-x: auto;
|
||||
display: none;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.my-ads-table-wrap {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.my-ads-cards-mobile {
|
||||
display: block;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.my-ads-cards-mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.my-ads-mobile-card {
|
||||
padding: 20px;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.my-ads-mobile-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.my-ads-mobile-row1 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.my-ads-mobile-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.my-ads-mobile-row2 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.my-ads-mobile-actions {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.my-ads-mobile-action-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
.my-ads-mobile-action-link:hover {
|
||||
background: rgba(249, 115, 22, 0.1);
|
||||
}
|
||||
.my-ads-mobile-action-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.my-ads-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.my-ads-table th, .my-ads-table td {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.my-ads-table th { font-size: 12px; color: var(--text-soft); text-transform: uppercase; }
|
||||
.my-ads-status { font-size: 12px; padding: 4px 8px; border-radius: 4px; }
|
||||
.my-ads-status--active { background: rgba(34, 197, 94, 0.2); color: var(--accent-soft); }
|
||||
.my-ads-status--pending_payment { background: rgba(249, 115, 22, 0.2); color: var(--accent); }
|
||||
.my-ads-status--expired { background: var(--bg-card-hover); color: var(--text-muted); }
|
||||
.my-ads-table a { color: var(--accent); }
|
||||
.my-ads-pay-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.my-ads-pay-btn:hover:not(:disabled) {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.my-ads-pay-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ export default defineConfig(({ mode }) => {
|
||||
"/config": { target: backendTarget, changeOrigin: true },
|
||||
"/stats": { target: backendTarget, changeOrigin: true },
|
||||
"/deposit": { target: backendTarget, changeOrigin: true },
|
||||
"/sponsor/": { target: backendTarget, changeOrigin: true },
|
||||
"/health": { target: backendTarget, changeOrigin: true },
|
||||
"/openapi.json": { target: backendTarget, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user