From 2ffbddff2ed0b7e9aaebb99c072444f11c35985f Mon Sep 17 00:00:00 2001 From: eric Date: Mon, 30 Mar 2026 10:36:08 -0500 Subject: [PATCH] 's' --- app/api/auth/link-email/route.ts | 1 + app/api/auth/store-email-link/route.ts | 1 + app/api/promo/recognize-discount/route.ts | 52 ++++ app/lib/pocketbase/auth.ts | 7 +- app/vip/components/LandingPage.tsx | 8 +- app/vip/components/landing/LandingCTA.tsx | 2 +- .../components/landing/PromoProofModal.tsx | 134 +++++++++ app/vip/components/vip.module.css | 255 +++++++++++++++++- 8 files changed, 454 insertions(+), 6 deletions(-) create mode 100644 app/api/promo/recognize-discount/route.ts create mode 100644 app/vip/components/landing/PromoProofModal.tsx diff --git a/app/api/auth/link-email/route.ts b/app/api/auth/link-email/route.ts index e80b9eb..f25ec2b 100644 --- a/app/api/auth/link-email/route.ts +++ b/app/api/auth/link-email/route.ts @@ -27,6 +27,7 @@ async function registerOrLoginUser( email, password, passwordConfirm: password, + live_allowed: true, }), cache: "no-store", }).catch(() => null); diff --git a/app/api/auth/store-email-link/route.ts b/app/api/auth/store-email-link/route.ts index 28e94b8..edbcda3 100644 --- a/app/api/auth/store-email-link/route.ts +++ b/app/api/auth/store-email-link/route.ts @@ -33,6 +33,7 @@ async function ensurePbUserExists(pbUrl: string, email: string): Promise { email, password: DEFAULT_PASSWORD, passwordConfirm: DEFAULT_PASSWORD, + live_allowed: true, }), }); if (registerRes.ok) { diff --git a/app/api/promo/recognize-discount/route.ts b/app/api/promo/recognize-discount/route.ts new file mode 100644 index 0000000..13fa2c6 --- /dev/null +++ b/app/api/promo/recognize-discount/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getApiUrlFromRequest, getPaymentConfig } from "@/config/services.config"; + +function resolvePayApiUrl(request: NextRequest): string { + const hostHeader = + request.headers.get("host") || request.headers.get("x-forwarded-host"); + return ( + (hostHeader ? getApiUrlFromRequest(hostHeader) : null) || + getPaymentConfig().apiUrl + ).replace(/\/$/, ""); +} + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + const image = formData.get("image"); + if (!(image instanceof File)) { + return NextResponse.json( + { ok: false, error: "缺少图片文件 image" }, + { status: 400 } + ); + } + + const payload = new FormData(); + payload.append("image", image, image.name || "proof.jpg"); + + const apiUrl = resolvePayApiUrl(request); + const res = await fetch(`${apiUrl}/nomadvip/recognize_discount_proof`, { + method: "POST", + body: payload, + cache: "no-store", + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + return NextResponse.json( + { ok: false, error: data?.error || "识别服务异常" }, + { status: res.status } + ); + } + return NextResponse.json(data); + } catch (error) { + return NextResponse.json( + { + ok: false, + error: error instanceof Error ? error.message : "识别请求失败", + }, + { status: 500 } + ); + } +} + diff --git a/app/lib/pocketbase/auth.ts b/app/lib/pocketbase/auth.ts index 97a6018..e76a1de 100644 --- a/app/lib/pocketbase/auth.ts +++ b/app/lib/pocketbase/auth.ts @@ -73,7 +73,12 @@ export async function pbRegister( const res = await fetch(`${url}/api/collections/users/records`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password, passwordConfirm }), + body: JSON.stringify({ + email, + password, + passwordConfirm, + live_allowed: true, + }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); diff --git a/app/vip/components/LandingPage.tsx b/app/vip/components/LandingPage.tsx index 7b985e1..1f83d9d 100644 --- a/app/vip/components/LandingPage.tsx +++ b/app/vip/components/LandingPage.tsx @@ -9,6 +9,7 @@ import { CommunityPreview } from "./landing/CommunityPreview"; import { FAQSection } from "./landing/FAQSection"; import { LandingCTA } from "./landing/LandingCTA"; import { LoginModal } from "./landing/LoginModal"; +import { PromoProofModal } from "./landing/PromoProofModal"; import styles from "./vip.module.css"; type LandingPageProps = { @@ -29,6 +30,7 @@ export function LandingPage({ onVerifySuccess, }: LandingPageProps) { const [loginModalOpen, setLoginModalOpen] = useState(false); + const [promoModalOpen, setPromoModalOpen] = useState(false); return ( <> @@ -49,7 +51,10 @@ export function LandingPage({ - setLoginModalOpen(true)} /> + setLoginModalOpen(true)} + /> + setPromoModalOpen(false)} /> ); } diff --git a/app/vip/components/landing/LandingCTA.tsx b/app/vip/components/landing/LandingCTA.tsx index 7f54641..86231bf 100644 --- a/app/vip/components/landing/LandingCTA.tsx +++ b/app/vip/components/landing/LandingCTA.tsx @@ -24,7 +24,7 @@ export function LandingCTA({ onJoin, onLogin }: LandingCTAProps) {

- + 限时特惠 diff --git a/app/vip/components/landing/PromoProofModal.tsx b/app/vip/components/landing/PromoProofModal.tsx new file mode 100644 index 0000000..501b9a8 --- /dev/null +++ b/app/vip/components/landing/PromoProofModal.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { type ChangeEvent, useRef, useState } from "react"; +import styles from "../vip.module.css"; + +type PromoProofModalProps = { + open: boolean; + onClose: () => void; +}; + +type RecognizeResult = { + ok: boolean; + nickname?: string; + avatar_image_base64?: string; + avatar_mime?: string; + confidence?: { + nickname?: number; + avatar?: number; + overall?: number; + }; + message?: string; + error?: string; +}; + +export function PromoProofModal({ open, onClose }: PromoProofModalProps) { + const fileInputRef = useRef(null); + const [confirmed, setConfirmed] = useState(false); + const [uploading, setUploading] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(""); + + if (!open) return null; + + const onPickFile = () => { + fileInputRef.current?.click(); + }; + + const onFileChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + setError(""); + setResult(null); + setUploading(true); + try { + const form = new FormData(); + form.append("image", file); + const res = await fetch("/api/promo/recognize-discount", { + method: "POST", + body: form, + }); + const data = (await res.json().catch(() => ({}))) as RecognizeResult; + if (!res.ok || !data?.ok) { + setError(data?.error || data?.message || "识别失败,请换一张清晰截图再试"); + } else { + setResult(data); + } + } catch (e) { + setError(e instanceof Error ? e.message : "上传失败,请稍后重试"); + } finally { + setUploading(false); + event.target.value = ""; + } + }; + + const avatarSrc = + result?.avatar_image_base64 && result?.avatar_mime + ? `data:${result.avatar_mime};base64,${result.avatar_image_base64}` + : null; + + return ( +
+
+ +
+
限时优惠任务
+

推荐任意公众号文章,立减 50 元

+

+ 在手机里打开任意一篇公众号文章,点击文末推荐(心形 icon),然后上传截图。系统将识别 + 头像 + 昵称 + 心形推荐痕迹 + 作为优惠凭证。 +

+

示例参考:页面左下角「异地世界」上方那一行(头像 / Eric / 爱心)。

+
+ + {!confirmed ? ( +
+ +
+ ) : ( +
+ + +

支持 jpg/png/webp,建议宽度大于 720px 且文字清晰

+ + {error ?

{error}

: null} + + {result?.ok ? ( +
+

识别结果

+
+
+ {avatarSrc ? ( + 识别头像 + ) : ( +
无头像
+ )} +
+
+

昵称

+

{result.nickname || "未识别"}

+
+
+
+ ) : null} +
+ )} +
+
+ ); +} + diff --git a/app/vip/components/vip.module.css b/app/vip/components/vip.module.css index 21b5ca0..1a7ac39 100644 --- a/app/vip/components/vip.module.css +++ b/app/vip/components/vip.module.css @@ -1048,10 +1048,30 @@ } .dark .ctaCard { - background: rgba(20, 20, 26, 0.78); + background: + radial-gradient(90% 70% at 50% -10%, rgba(251, 191, 36, 0.12) 0%, rgba(251, 191, 36, 0) 58%), + linear-gradient(180deg, #0b1220 0%, #0a0f1a 100%); + border-color: rgba(251, 191, 36, 0.18); box-shadow: - 0 24px 56px rgba(0, 0, 0, 0.45), - 0 0 0 1px rgba(255, 255, 255, 0.06) inset; + 0 24px 60px rgba(0, 0, 0, 0.52), + 0 0 0 1px rgba(251, 191, 36, 0.08) inset, + 0 1px 0 rgba(255, 255, 255, 0.04) inset; +} + +.dark .ctaGlow { + opacity: 0.55; + filter: blur(10px); +} + +.dark .ctaPromoLine { + color: rgba(255, 255, 255, 0.92); +} + +.dark .ctaHighlight { + background: linear-gradient(120deg, #f59e0b 0%, #fcd34d 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; } @media (min-width: 640px) { @@ -1381,3 +1401,232 @@ transform: translateY(-1px); background: var(--muted); } + +.ctaPromoClickable { + appearance: none; + -webkit-appearance: none; + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.28rem 0.65rem; + font-size: 0.7em; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--accent); + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.35); + border-radius: 9999px; + cursor: pointer; + font-family: inherit; + transition: transform 0.15s, box-shadow 0.15s, background 0.15s; +} + +.ctaPromoClickable:hover { + transform: translateY(-1px); + background: rgba(245, 158, 11, 0.18); + box-shadow: 0 8px 20px rgba(245, 158, 11, 0.18); +} + +.ctaPromoClickable:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.dark .ctaPromoClickable { + color: #fbbf24; + background: rgba(245, 158, 11, 0.16); + border-color: rgba(251, 191, 36, 0.42); + box-shadow: 0 0 0 1px rgba(251, 191, 36, 0.1) inset; +} + +.dark .ctaPromoClickable:hover { + background: rgba(245, 158, 11, 0.24); + box-shadow: + 0 12px 28px rgba(0, 0, 0, 0.42), + 0 0 0 1px rgba(251, 191, 36, 0.18) inset; +} + +.dark .ctaPromoDivider { + color: rgba(251, 191, 36, 0.58); +} + +.dark .ctaPromoSuffix { + color: rgba(255, 255, 255, 0.88); +} + +.dark .ctaChip { + color: rgba(255, 255, 255, 0.82); + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); +} + +.dark .ctaBtnSecondary { + color: rgba(255, 255, 255, 0.92); + border-color: rgba(251, 191, 36, 0.26); + background: rgba(255, 255, 255, 0.03); +} + +.dark .ctaBtnSecondary:hover { + background: rgba(251, 191, 36, 0.1); + border-color: rgba(251, 191, 36, 0.42); + color: #fde68a; +} + +/* 促销识别弹窗 */ +.promoModalPanel { + position: relative; + width: 100%; + max-width: 520px; + padding: 1.4rem 1.2rem 1.2rem; + border-radius: 16px; + border: 1px solid rgba(245, 158, 11, 0.25); + background: var(--card); + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.35); +} + +.promoModalTop { + padding-right: 1.25rem; +} + +.promoModalTag { + display: inline-block; + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--accent); + border: 1px solid rgba(245, 158, 11, 0.3); + background: rgba(245, 158, 11, 0.12); + border-radius: 999px; + padding: 0.18rem 0.58rem; + margin-bottom: 0.65rem; +} + +.promoModalTitle { + margin: 0 0 0.35rem; + font-size: 1.1rem; + color: var(--foreground); +} + +.promoModalDesc { + margin: 0 0 0.55rem; + line-height: 1.55; + font-size: 0.9rem; + color: var(--muted-foreground); +} + +.promoModalHint { + margin: 0 0 0.9rem; + font-size: 0.78rem; + color: var(--muted-foreground); +} + +.promoModalActions { + margin-top: 0.5rem; +} + +.promoPrimaryBtn { + width: 100%; +} + +.promoUploadArea { + margin-top: 0.4rem; + border: 1px dashed rgba(245, 158, 11, 0.34); + border-radius: 12px; + padding: 0.9rem; + background: rgba(245, 158, 11, 0.04); +} + +.promoFileInput { + display: none; +} + +.promoUploadBtn { + width: 100%; + border: 1px solid rgba(245, 158, 11, 0.42); + background: linear-gradient(135deg, rgba(245, 158, 11, 0.18), rgba(245, 158, 11, 0.08)); + color: var(--foreground); + font-size: 0.92rem; + font-weight: 600; + border-radius: 10px; + padding: 0.72rem 0.9rem; + cursor: pointer; +} + +.promoUploadBtn:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.promoUploadTip { + margin: 0.45rem 0 0; + color: var(--muted-foreground); + font-size: 0.76rem; +} + +.promoError { + margin: 0.75rem 0 0; + font-size: 0.82rem; + color: #ef4444; +} + +.promoResultCard { + margin-top: 0.85rem; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--muted); + padding: 0.8rem; +} + +.promoResultTitle { + margin: 0 0 0.5rem; + font-size: 0.83rem; + color: var(--muted-foreground); +} + +.promoResultRow { + display: flex; + align-items: center; + gap: 0.72rem; +} + +.promoAvatarWrap { + width: 64px; + height: 64px; + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--border); + background: var(--card); + display: flex; + align-items: center; + justify-content: center; +} + +.promoAvatarImg { + width: 100%; + height: 100%; + object-fit: cover; +} + +.promoAvatarEmpty { + font-size: 0.72rem; + color: var(--muted-foreground); +} + +.promoResultMeta { + min-width: 0; +} + +.promoNicknameLabel { + margin: 0 0 0.15rem; + font-size: 0.76rem; + color: var(--muted-foreground); +} + +.promoNicknameValue { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--foreground); +}