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`);
|
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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user