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:
@@ -445,3 +445,20 @@ export async function postSponsorRegenerateInvoice(sponsorId: number): Promise<{
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
158
frontend/src/components/SponsorEditModal.tsx
Normal file
158
frontend/src/components/SponsorEditModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { getSponsorMyAds, getToken, postSponsorRegenerateInvoice } from "../api";
|
||||
import { SponsorInvoiceModal } from "../components/SponsorInvoiceModal";
|
||||
import { SponsorEditModal } from "../components/SponsorEditModal";
|
||||
import type { SponsorMyAd } from "../api";
|
||||
|
||||
function formatStatus(s: string): string {
|
||||
@@ -26,6 +27,7 @@ export function MyAdsPage() {
|
||||
duration_days: number;
|
||||
} | null>(null);
|
||||
const [invoiceLoading, setInvoiceLoading] = useState(false);
|
||||
const [editingAd, setEditingAd] = useState<SponsorMyAd | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "My Ads — Sats Faucet";
|
||||
@@ -55,14 +57,16 @@ export function MyAdsPage() {
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const refreshAds = useCallback(() => {
|
||||
const refreshAds = useCallback((opts?: { silent?: boolean }) => {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
if (!opts?.silent) setLoading(true);
|
||||
getSponsorMyAds()
|
||||
.then(setAds)
|
||||
.catch(() => setAds([]))
|
||||
.finally(() => setLoading(false));
|
||||
.finally(() => {
|
||||
if (!opts?.silent) setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePayInvoice = useCallback(async (ad: SponsorMyAd) => {
|
||||
@@ -137,7 +141,16 @@ export function MyAdsPage() {
|
||||
<td>{getDaysLeft(ad.expires_at)} days</td>
|
||||
<td>{ad.views}</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" ? (
|
||||
<Link to={`/sponsors?extend=${ad.id}`}>Extend</Link>
|
||||
) : ad.status === "pending_payment" ? (
|
||||
@@ -170,6 +183,15 @@ export function MyAdsPage() {
|
||||
<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 === "pending_payment") && (
|
||||
<button
|
||||
type="button"
|
||||
className="my-ads-mobile-action-link"
|
||||
onClick={() => setEditingAd(ad)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{ad.status === "active" || ad.status === "expired" ? (
|
||||
<Link to={`/sponsors?extend=${ad.id}`} className="my-ads-mobile-action-link">Extend</Link>
|
||||
) : ad.status === "pending_payment" ? (
|
||||
@@ -200,6 +222,13 @@ export function MyAdsPage() {
|
||||
result={pendingInvoice}
|
||||
onPaid={refreshAds}
|
||||
/>
|
||||
|
||||
<SponsorEditModal
|
||||
open={editingAd !== null}
|
||||
ad={editingAd}
|
||||
onClose={() => setEditingAd(null)}
|
||||
onSaved={() => refreshAds({ silent: true })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3296,6 +3296,10 @@ h1 {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.my-ads-mobile-action-link {
|
||||
display: inline-flex;
|
||||
@@ -3351,3 +3355,31 @@ h1 {
|
||||
opacity: 0.6;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user