feat(frontend): allow sponsors to edit ads from My Ads

Add patchSponsor API, SponsorEditModal for title/description/URLs, and
Edit actions on desktop and mobile. Server PATCH /sponsor/:id already
enforces creator-only edits.

Made-with: Cursor
This commit is contained in:
SatsFaucet
2026-04-06 20:23:39 +02:00
parent 5d02d1396f
commit 6ece47e3ed
4 changed files with 240 additions and 4 deletions

View File

@@ -445,3 +445,20 @@ export async function postSponsorRegenerateInvoice(sponsorId: number): Promise<{
} }
return requestWithNip98("POST", `/sponsor/${sponsorId}/regenerate-invoice`); return requestWithNip98("POST", `/sponsor/${sponsorId}/regenerate-invoice`);
} }
export async function patchSponsor(
sponsorId: number,
body: {
title?: string;
description?: string;
link_url?: string;
image_url?: string;
category?: string;
lightning_address?: string;
}
): Promise<SponsorMyAd> {
if (getToken()) {
return requestWithBearer<SponsorMyAd>("PATCH", `/sponsor/${sponsorId}`, body);
}
return requestWithNip98<SponsorMyAd>("PATCH", `/sponsor/${sponsorId}`, body);
}

View File

@@ -0,0 +1,158 @@
import { useState, useEffect, useCallback } from "react";
import { Modal } from "./Modal";
import { patchSponsor, type ApiError, type SponsorMyAd } from "../api";
interface SponsorEditModalProps {
open: boolean;
ad: SponsorMyAd | null;
onClose: () => void;
/** Called after a successful save with the updated row from the server. */
onSaved?: (updated: SponsorMyAd) => void;
}
export function SponsorEditModal({ open, ad, onClose, onSaved }: SponsorEditModalProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [linkUrl, setLinkUrl] = useState("");
const [imageUrl, setImageUrl] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !ad) return;
setTitle(ad.title);
setDescription(ad.description);
setLinkUrl(ad.link_url);
setImageUrl(ad.image_url ?? "");
setError(null);
}, [open, ad]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!ad) return;
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 updated = await patchSponsor(ad.id, {
title: title.trim(),
description: description.trim(),
link_url: linkUrl.trim(),
image_url: imageUrl.trim() || undefined,
});
onSaved?.(updated);
onClose();
} catch (e) {
const msg =
e && typeof e === "object" && "message" in e
? String((e as ApiError).message)
: "Failed to save changes";
setError(msg);
} finally {
setLoading(false);
}
},
[ad, title, description, linkUrl, imageUrl, onSaved, onClose]
);
if (!ad) return null;
return (
<Modal
open={open}
onClose={onClose}
title="Edit sponsor ad"
variant="sponsor"
preventClose={loading}
>
<form className="sponsor-form sponsor-edit-form" onSubmit={handleSubmit}>
<div className="sponsor-form-row">
<label htmlFor="sponsor-edit-title" className="sponsor-form-label">
Title <span className="required">*</span>
</label>
<input
id="sponsor-edit-title"
type="text"
className="sponsor-form-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Your project or product name"
maxLength={100}
required
disabled={loading}
/>
</div>
<div className="sponsor-form-row">
<label htmlFor="sponsor-edit-desc" className="sponsor-form-label">
Short description <span className="required">*</span>
</label>
<textarea
id="sponsor-edit-desc"
className="sponsor-form-textarea"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description (max 500 chars)"
maxLength={500}
rows={3}
required
disabled={loading}
/>
</div>
<div className="sponsor-form-row">
<label htmlFor="sponsor-edit-link" className="sponsor-form-label">
Destination URL <span className="required">*</span>
</label>
<input
id="sponsor-edit-link"
type="url"
className="sponsor-form-input"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder="https://..."
required
disabled={loading}
/>
</div>
<div className="sponsor-form-row">
<label htmlFor="sponsor-edit-image" className="sponsor-form-label">
Image URL <span className="optional">(optional)</span>
</label>
<input
id="sponsor-edit-image"
type="url"
className="sponsor-form-input"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://..."
disabled={loading}
/>
</div>
{error && (
<p className="sponsor-form-error" role="alert">
{error}
</p>
)}
<div className="sponsor-form-actions">
<button type="button" className="sponsor-form-btn secondary" onClick={onClose} disabled={loading}>
Cancel
</button>
<button type="submit" className="sponsor-form-btn primary" disabled={loading}>
{loading ? "Saving…" : "Save changes"}
</button>
</div>
</form>
</Modal>
);
}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { getSponsorMyAds, getToken, postSponsorRegenerateInvoice } from "../api"; import { getSponsorMyAds, getToken, postSponsorRegenerateInvoice } from "../api";
import { SponsorInvoiceModal } from "../components/SponsorInvoiceModal"; import { SponsorInvoiceModal } from "../components/SponsorInvoiceModal";
import { SponsorEditModal } from "../components/SponsorEditModal";
import type { SponsorMyAd } from "../api"; import type { SponsorMyAd } from "../api";
function formatStatus(s: string): string { function formatStatus(s: string): string {
@@ -26,6 +27,7 @@ export function MyAdsPage() {
duration_days: number; duration_days: number;
} | null>(null); } | null>(null);
const [invoiceLoading, setInvoiceLoading] = useState(false); const [invoiceLoading, setInvoiceLoading] = useState(false);
const [editingAd, setEditingAd] = useState<SponsorMyAd | null>(null);
useEffect(() => { useEffect(() => {
document.title = "My Ads — Sats Faucet"; document.title = "My Ads — Sats Faucet";
@@ -55,14 +57,16 @@ export function MyAdsPage() {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, []);
const refreshAds = useCallback(() => { const refreshAds = useCallback((opts?: { silent?: boolean }) => {
const token = getToken(); const token = getToken();
if (!token) return; if (!token) return;
setLoading(true); if (!opts?.silent) setLoading(true);
getSponsorMyAds() getSponsorMyAds()
.then(setAds) .then(setAds)
.catch(() => setAds([])) .catch(() => setAds([]))
.finally(() => setLoading(false)); .finally(() => {
if (!opts?.silent) setLoading(false);
});
}, []); }, []);
const handlePayInvoice = useCallback(async (ad: SponsorMyAd) => { const handlePayInvoice = useCallback(async (ad: SponsorMyAd) => {
@@ -137,7 +141,16 @@ export function MyAdsPage() {
<td>{getDaysLeft(ad.expires_at)} days</td> <td>{getDaysLeft(ad.expires_at)} days</td>
<td>{ad.views}</td> <td>{ad.views}</td>
<td>{ad.clicks}</td> <td>{ad.clicks}</td>
<td> <td className="my-ads-actions-cell">
{(ad.status === "active" || ad.status === "pending_payment") && (
<button
type="button"
className="my-ads-edit-btn"
onClick={() => setEditingAd(ad)}
>
Edit
</button>
)}
{ad.status === "active" || ad.status === "expired" ? ( {ad.status === "active" || ad.status === "expired" ? (
<Link to={`/sponsors?extend=${ad.id}`}>Extend</Link> <Link to={`/sponsors?extend=${ad.id}`}>Extend</Link>
) : ad.status === "pending_payment" ? ( ) : ad.status === "pending_payment" ? (
@@ -170,6 +183,15 @@ export function MyAdsPage() {
<span className="my-ads-mobile-meta">{ad.views} views · {ad.clicks} clicks</span> <span className="my-ads-mobile-meta">{ad.views} views · {ad.clicks} clicks</span>
</div> </div>
<div className="my-ads-mobile-actions"> <div className="my-ads-mobile-actions">
{(ad.status === "active" || ad.status === "pending_payment") && (
<button
type="button"
className="my-ads-mobile-action-link"
onClick={() => setEditingAd(ad)}
>
Edit
</button>
)}
{ad.status === "active" || ad.status === "expired" ? ( {ad.status === "active" || ad.status === "expired" ? (
<Link to={`/sponsors?extend=${ad.id}`} className="my-ads-mobile-action-link">Extend</Link> <Link to={`/sponsors?extend=${ad.id}`} className="my-ads-mobile-action-link">Extend</Link>
) : ad.status === "pending_payment" ? ( ) : ad.status === "pending_payment" ? (
@@ -200,6 +222,13 @@ export function MyAdsPage() {
result={pendingInvoice} result={pendingInvoice}
onPaid={refreshAds} onPaid={refreshAds}
/> />
<SponsorEditModal
open={editingAd !== null}
ad={editingAd}
onClose={() => setEditingAd(null)}
onSaved={() => refreshAds({ silent: true })}
/>
</div> </div>
); );
} }

View File

@@ -3296,6 +3296,10 @@ h1 {
margin-top: 12px; margin-top: 12px;
padding-top: 12px; padding-top: 12px;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
} }
.my-ads-mobile-action-link { .my-ads-mobile-action-link {
display: inline-flex; display: inline-flex;
@@ -3351,3 +3355,31 @@ h1 {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.my-ads-actions-cell {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
}
.my-ads-edit-btn {
background: none;
border: none;
padding: 0;
font-size: 14px;
font-weight: 500;
color: var(--accent);
cursor: pointer;
text-decoration: underline;
}
.my-ads-edit-btn:hover:not(:disabled) {
color: var(--accent-hover);
}
.my-ads-edit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.sponsor-edit-form {
margin-top: 4px;
}