Files
jiang13-bbs/frontend/app/ads/buy/AdsBuyClient.tsx

575 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
RefreshCw,
ImageIcon,
Type,
CheckCircle2,
Mail,
Link2,
Shield,
AlertCircle,
} from "lucide-react";
import {
apiAdsPublicConfig,
apiFetchCaptcha,
apiSubmitAd,
type AdConfig,
type AdPaymentOption,
} from "@/lib/api";
import ImageLightbox, { type LightboxItem } from "@/components/markdown/ImageLightbox";
import { AdText } from "@/components/AdText";
import { toast } from "@/lib/toast";
import {
AD_IMAGE_HEIGHT,
AD_IMAGE_SIZE_LABEL,
AD_IMAGE_SIZE_LABEL_2X,
AD_IMAGE_WIDTH,
AD_TEXT_COL_WIDTH,
} from "@/lib/ads";
type Kind = "image" | "text";
export default function AdsBuyClient() {
const [cfg, setCfg] = useState<AdConfig | null>(null);
const [loadErr, setLoadErr] = useState("");
const [kind, setKind] = useState<Kind>("image");
const [email, setEmail] = useState("");
const [link, setLink] = useState("");
const [imageURL, setImageURL] = useState("");
const [title, setTitle] = useState("");
const [textColor, setTextColor] = useState("#1E293B");
const [bgColor, setBgColor] = useState("#F1F5F9");
const [days, setDays] = useState(0);
const [paymentID, setPaymentID] = useState("");
const [note, setNote] = useState("");
const [captchaID, setCaptchaID] = useState("");
const [captchaImg, setCaptchaImg] = useState("");
const [captchaCode, setCaptchaCode] = useState("");
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const [lightbox, setLightbox] = useState<{ items: LightboxItem[]; index: number } | null>(null);
const refreshCaptcha = useCallback(async () => {
try {
const c = await apiFetchCaptcha();
setCaptchaID(c.id);
setCaptchaImg(c.image);
setCaptchaCode("");
} catch (e) {
toast(e instanceof Error ? e.message : "验证码加载失败", "error");
}
}, []);
useEffect(() => {
let alive = true;
apiAdsPublicConfig()
.then((c) => {
if (!alive) return;
setCfg(c);
if (c.durations[0]) setDays(c.durations[0].days);
const pay = c.payments.find((p) => p.enabled && p.qr_url);
if (pay) setPaymentID(pay.id);
})
.catch((e) => {
if (alive) setLoadErr(e instanceof Error ? e.message : "加载失败");
});
void refreshCaptcha();
return () => {
alive = false;
};
}, [refreshCaptcha]);
const selectedPay: AdPaymentOption | undefined = cfg?.payments.find((p) => p.id === paymentID);
const selectedDuration = cfg?.durations.find((d) => d.days === days);
const canSubmit = !!cfg && cfg.payments.length > 0 && !busy;
const kindLabel = kind === "image" ? "图片广告" : "文字广告";
const openLightbox = (src: string, alt = "") => {
setLightbox({ items: [{ src, alt }], index: 0 });
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
setBusy(true);
try {
await apiSubmitAd({
kind,
contact_email: email,
link_url: link,
image_url: kind === "image" ? imageURL : "",
title: kind === "text" ? title : "",
text_color: kind === "text" ? textColor : "",
bg_color: kind === "text" ? bgColor : "",
duration_days: days,
payment_id: paymentID,
buyer_note: note,
captcha_id: captchaID,
captcha_code: captchaCode,
});
setDone(true);
toast("提交成功,请完成付款并等待审核", "ok");
} catch (err) {
toast(err instanceof Error ? err.message : "提交失败", "error");
void refreshCaptcha();
} finally {
setBusy(false);
}
};
if (loadErr) {
return (
<Shell>
<div className="j13-ads-card p-8 text-center">
<p className="font-semibold" style={{ color: "var(--ink)" }}>
{loadErr}
</p>
<Link href="/" className="btn btn-line mt-5">
返回首页
</Link>
</div>
</Shell>
);
}
if (!cfg) {
return (
<Shell>
<div className="j13-ads-card p-6">
<p className="meta">加载中…</p>
</div>
</Shell>
);
}
if (!cfg.enabled) {
return (
<Shell>
<div className="j13-ads-card p-8 text-center">
<p className="text-lg font-semibold" style={{ color: "var(--ink)" }}>
自助推广暂未开放
</p>
<Link href="/" className="btn btn-primary mt-5">
返回首页
</Link>
</div>
</Shell>
);
}
if (done) {
return (
<Shell>
<div className="j13-ads-card overflow-hidden text-center">
<div className="j13-ads-card-head">
<h1 className="text-[17px] font-bold">申请已提交</h1>
</div>
<div className="space-y-4 px-5 py-6">
<CheckCircle2 size={36} className="mx-auto" style={{ color: "var(--ok)" }} />
<p className="meta mx-auto max-w-sm text-[13px] leading-relaxed">
请完成付款,管理员确认后将出现在首页侧栏。
</p>
{selectedPay?.qr_url ? (
<button
type="button"
className="mx-auto block cursor-zoom-in"
onClick={() => openLightbox(selectedPay.qr_url, `${selectedPay.name}收款码`)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={selectedPay.qr_url}
alt="收款码"
className="mx-auto h-40 w-40 rounded-xl border bg-white object-contain p-2"
style={{ borderColor: "var(--line)" }}
/>
</button>
) : null}
<div className="flex justify-center gap-3 pt-1">
<Link href="/" className="btn btn-primary">
返回首页
</Link>
<button
type="button"
className="btn btn-line"
onClick={() => {
setDone(false);
void refreshCaptcha();
}}
>
再投一条
</button>
</div>
</div>
</div>
{lightbox ? (
<ImageLightbox
items={lightbox.items}
index={lightbox.index}
onClose={() => setLightbox(null)}
onIndexChange={(i) => setLightbox((prev) => (prev ? { ...prev, index: i } : prev))}
/>
) : null}
</Shell>
);
}
return (
<Shell>
<form onSubmit={onSubmit} className="j13-ads-card overflow-hidden">
<div className="j13-ads-card-head">
<h1 className="text-[17px] font-bold tracking-tight">自助购买广告位</h1>
<p className="text-[12.5px]">
{kindLabel}
{selectedDuration ? ` · ${selectedDuration.label}` : ""}
</p>
</div>
<div className="space-y-3 px-4 py-4 sm:px-5 sm:py-5">
<AdPreview
kind={kind}
imageURL={imageURL}
title={title}
textColor={textColor}
bgColor={bgColor}
onImageClick={() => openLightbox(imageURL, "图片广告预览")}
/>
<div className="grid grid-cols-2 gap-2">
<ChipTab active={kind === "image"} onClick={() => setKind("image")}>
<ImageIcon size={15} /> 图片广告
</ChipTab>
<ChipTab active={kind === "text"} onClick={() => setKind("text")}>
<Type size={15} /> 文字广告
</ChipTab>
</div>
<IconField icon={<Mail size={15} />}>
<input
type="email"
required
className="j13-ads-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="联系邮箱"
autoComplete="email"
/>
</IconField>
<IconField icon={<Link2 size={15} />}>
<input
type="url"
required
className="j13-ads-input"
value={link}
onChange={(e) => setLink(e.target.value)}
placeholder="广告链接,带 https"
/>
</IconField>
{kind === "image" ? (
<>
<IconField icon={<ImageIcon size={15} />}>
<input
type="url"
required
className="j13-ads-input"
value={imageURL}
onChange={(e) => setImageURL(e.target.value)}
placeholder={`图片地址,带 https,尺寸 ${AD_IMAGE_SIZE_LABEL}(高清 ${AD_IMAGE_SIZE_LABEL_2X})`}
/>
</IconField>
</>
) : (
<>
<IconField icon={<Type size={15} />}>
<input
type="text"
required
maxLength={24}
className="j13-ads-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="广告标题,最多 24 字"
/>
</IconField>
<div className="grid grid-cols-2 gap-2">
<label className="j13-ads-field flex items-center gap-2 px-2.5">
<input
type="color"
value={normalizeHex(textColor)}
onChange={(e) => setTextColor(e.target.value.toUpperCase())}
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 bg-transparent"
/>
<input
className="j13-ads-input !pl-0 font-mono text-[12.5px]"
value={textColor}
onChange={(e) => setTextColor(e.target.value)}
aria-label="文字颜色"
/>
</label>
<label className="j13-ads-field flex items-center gap-2 px-2.5">
<input
type="color"
value={normalizeHex(bgColor)}
onChange={(e) => setBgColor(e.target.value.toUpperCase())}
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 bg-transparent"
/>
<input
className="j13-ads-input !pl-0 font-mono text-[12.5px]"
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
aria-label="背景颜色"
/>
</label>
</div>
</>
)}
<div className="grid grid-cols-5 gap-2">
{cfg.durations.map((d) => {
const active = days === d.days;
return (
<ChipTab key={d.days} active={active} onClick={() => setDays(d.days)} compact>
{d.label.replace(/\s/g, "")}
</ChipTab>
);
})}
</div>
{cfg.payments.length === 0 ? (
<div
className="flex gap-2 rounded-lg border px-3 py-2.5 text-[12.5px] leading-relaxed"
style={{
borderColor: "color-mix(in srgb, var(--accent) 28%, var(--line))",
background: "var(--accent-soft)",
color: "var(--ink-2)",
}}
>
<AlertCircle size={14} className="mt-0.5 shrink-0" style={{ color: "var(--accent)" }} />
<span>站长尚未配置收款码。</span>
</div>
) : (
<>
<div className="flex flex-wrap gap-2">
{cfg.payments.map((p) => (
<ChipTab key={p.id} active={paymentID === p.id} onClick={() => setPaymentID(p.id)}>
{p.name}
</ChipTab>
))}
</div>
{selectedPay?.qr_url ? (
<button
type="button"
className="mx-auto block cursor-zoom-in rounded-xl border bg-white p-2.5"
style={{ borderColor: "var(--line)" }}
onClick={() => openLightbox(selectedPay.qr_url, `${selectedPay.name}收款码`)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={selectedPay.qr_url}
alt={`${selectedPay.name}收款码`}
className="h-36 w-36 object-contain"
/>
</button>
) : null}
{selectedPay?.hint ? (
<p className="meta text-center text-[12px] leading-relaxed">{selectedPay.hint}</p>
) : null}
</>
)}
<IconField icon={<Mail size={15} />}>
<input
className="j13-ads-input"
maxLength={100}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="备注(可选)"
/>
</IconField>
<div className="flex items-stretch gap-2">
<IconField icon={<Shield size={15} />} className="min-w-0 flex-1">
<input
required
className="j13-ads-input uppercase tracking-widest"
value={captchaCode}
onChange={(e) => setCaptchaCode(e.target.value)}
placeholder="图片验证码"
autoComplete="off"
maxLength={8}
/>
</IconField>
<button
type="button"
onClick={() => void refreshCaptcha()}
className="shrink-0 overflow-hidden rounded-lg border"
style={{ borderColor: "var(--line)" }}
title="点击刷新"
aria-label="刷新验证码"
>
{captchaImg ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={captchaImg} alt="验证码" width={110} height={42} className="block h-[42px] w-[110px] object-cover" />
) : (
<span className="meta flex h-[42px] w-[110px] items-center justify-center text-[12px]">
<RefreshCw size={14} />
</span>
)}
</button>
</div>
<button type="submit" className="j13-ads-submit" disabled={!canSubmit}>
{busy ? "提交中…" : "提交申请"}
</button>
<p className="text-center">
<Link href="/" className="meta text-[12.5px] hover:text-[var(--accent)]">
返回首页
</Link>
</p>
</div>
</form>
{lightbox ? (
<ImageLightbox
items={lightbox.items}
index={lightbox.index}
onClose={() => setLightbox(null)}
onIndexChange={(i) => setLightbox((prev) => (prev ? { ...prev, index: i } : prev))}
/>
) : null}
</Shell>
);
}
function Shell({ children }: { children: React.ReactNode }) {
return <div className="mx-auto w-full max-w-[560px] px-4 py-6 sm:py-10">{children}</div>;
}
function ChipTab({
active,
onClick,
children,
compact,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
compact?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={`j13-ads-chip${active ? " is-on" : ""}${compact ? " is-compact" : ""}`}
>
{children}
</button>
);
}
function IconField({
icon,
children,
className = "",
}: {
icon: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<label className={`j13-ads-field ${className}`.trim()}>
<span className="j13-ads-field-ico" aria-hidden>
{icon}
</span>
{children}
</label>
);
}
/**
* 上线预览:复用首页侧栏 .j13-ad-promo 结构与样式,
* 图片位 248×60、文字位两列网格,尺寸/圆角与侧栏完全一致。
*/
function AdPreview({
kind,
imageURL,
title,
textColor,
bgColor,
onImageClick,
}: {
kind: Kind;
imageURL: string;
title: string;
textColor: string;
bgColor: string;
onImageClick: () => void;
}) {
const text = title.trim();
return (
<div className="text-center">
<p className="meta mb-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
上线预览 · 尺寸与首页侧栏一致
</p>
<div className="j13-ad-promo">
{kind === "image" ? (
<ul className="space-y-2" aria-label="图片广告预览">
<li>
{imageURL.startsWith("https://") ? (
<a
href={imageURL}
className="j13-ad-image-link"
onClick={(e) => {
e.preventDefault();
onImageClick();
}}
title="点击放大预览"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageURL}
alt="图片广告预览"
className="j13-ad-image"
width={AD_IMAGE_WIDTH}
height={AD_IMAGE_HEIGHT}
/>
</a>
) : (
<span className="j13-ad-slot j13-ad-slot-image">
图片广告位 {AD_IMAGE_SIZE_LABEL}
</span>
)}
</li>
</ul>
) : (
<ul
className="j13-ad-text-grid"
style={{ display: "flex", justifyContent: "center" }}
aria-label="文字广告预览"
>
<li style={{ width: AD_TEXT_COL_WIDTH, minWidth: 0 }}>
{text.length > 0 ? (
<AdText title={text} textColor={textColor} bgColor={bgColor} />
) : (
<span className="j13-ad-slot j13-ad-slot-text">文字广告位</span>
)}
</li>
</ul>
)}
</div>
</div>
);
}
function normalizeHex(v: string): string {
const s = v.trim();
if (/^#[0-9A-Fa-f]{6}$/.test(s)) return s;
if (/^[0-9A-Fa-f]{6}$/.test(s)) return `#${s}`;
return "#1E293B";
}