's'
This commit is contained in:
88
components/access-gate.tsx
Normal file
88
components/access-gate.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useTrack } from "@/hooks/use-track";
|
||||
import { TRACKING_EVENTS } from "@/lib/tracking-events";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
|
||||
export function AccessGate({ slug, children }: { slug: string; children: React.ReactNode }) {
|
||||
const { session, loginAs, setAccessLevel } = useAuth();
|
||||
const { track } = useTrack();
|
||||
const state = session.accessLevel;
|
||||
|
||||
const hint = useMemo(() => {
|
||||
if (state === "vip") return "当前状态:VIP。可访问全部下载渠道与历史版本包。";
|
||||
if (state === "unlocked") return "当前状态:已解锁。可访问部分高级下载源。";
|
||||
return "当前状态:游客。请先解锁或开通 VIP。";
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<section className="card-glass space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">下载权限门禁</h3>
|
||||
<p className="text-sm text-slate-300">{hint}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className={`pill ${state === "guest" ? "pill-active" : ""}`}
|
||||
onClick={() => setAccessLevel("guest")}
|
||||
>
|
||||
游客
|
||||
</button>
|
||||
<button
|
||||
className={`pill ${state === "unlocked" ? "pill-active" : ""}`}
|
||||
onClick={() => setAccessLevel("unlocked")}
|
||||
>
|
||||
已解锁
|
||||
</button>
|
||||
<button
|
||||
className={`pill ${state === "vip" ? "pill-active" : ""}`}
|
||||
onClick={() => setAccessLevel("vip")}
|
||||
>
|
||||
VIP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!session.isLoggedIn && (
|
||||
<div className="rounded-2xl border border-cyan-300/20 bg-cyan-400/10 p-3 text-sm text-slate-100">
|
||||
<p>当前未登录,建议先登录后再解锁下载权限。</p>
|
||||
<button
|
||||
className="cta-sub mt-2"
|
||||
onClick={() => {
|
||||
loginAs();
|
||||
track(TRACKING_EVENTS.ctaClick, { slug, target: "login_quick" });
|
||||
}}
|
||||
>
|
||||
一键模拟登录
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === "guest" && (
|
||||
<div className="rounded-2xl border border-amber-300/30 bg-amber-400/10 p-4 text-sm text-amber-100">
|
||||
<p>你还未解锁当前资源。</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/unlock"
|
||||
onClick={() => track(TRACKING_EVENTS.ctaClick, { slug, target: "unlock_center", gate: "guest" })}
|
||||
className="cta-sub"
|
||||
>
|
||||
去解锁中心
|
||||
</Link>
|
||||
<Link
|
||||
href={`/checkout?plan=pro&slug=${slug}`}
|
||||
onClick={() => track(TRACKING_EVENTS.ctaClick, { slug, target: "checkout", gate: "guest" })}
|
||||
className="cta-main"
|
||||
>
|
||||
开通 VIP
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state !== "guest" && children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
22
components/ambient-background.tsx
Normal file
22
components/ambient-background.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export function AmbientBackground() {
|
||||
const dots = Array.from({ length: 14 }, (_, i) => i);
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_10%,rgba(34,211,238,.14),transparent_38%),radial-gradient(circle_at_80%_0%,rgba(167,139,250,.16),transparent_42%)]" />
|
||||
{dots.map((i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
initial={{ opacity: 0.2, y: i * 30 }}
|
||||
animate={{ opacity: [0.2, 0.45, 0.2], y: [i * 30, i * 30 - 18, i * 30] }}
|
||||
transition={{ repeat: Infinity, duration: 6 + (i % 4), ease: "easeInOut", delay: i * 0.2 }}
|
||||
className="absolute h-1.5 w-1.5 rounded-full bg-cyan-200/70 blur-[1px]"
|
||||
style={{ left: `${8 + i * 6.3}%`, top: `${12 + (i % 6) * 12}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
components/app-detail.tsx
Normal file
77
components/app-detail.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { AppResource } from "@/lib/types";
|
||||
import { useTrack } from "@/hooks/use-track";
|
||||
import { buildChannelUrl } from "@/lib/marketing";
|
||||
import { TRACKING_EVENTS } from "@/lib/tracking-events";
|
||||
|
||||
export function AppMetaInfo({ app }: { app: AppResource }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
|
||||
<div className="card-glass"><p>版本</p><p className="mt-1 font-semibold">{app.version}</p></div>
|
||||
<div className="card-glass"><p>大小</p><p className="mt-1 font-semibold">{app.size}</p></div>
|
||||
<div className="card-glass"><p>更新日期</p><p className="mt-1 font-semibold">{app.updatedAt}</p></div>
|
||||
<div className="card-glass"><p>上传日期</p><p className="mt-1 font-semibold">{app.uploadedAt}</p></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadButtons({ app }: { app: AppResource }) {
|
||||
const { track } = useTrack();
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{app.channels.map((c) => (
|
||||
<a
|
||||
key={c.id}
|
||||
href={buildChannelUrl(c.href, c.name, app.slug)}
|
||||
onClick={() => track(TRACKING_EVENTS.downloadClick, { slug: app.slug, channel: c.name, sourceType: c.type, location: "detail" })}
|
||||
className="rounded-2xl border border-cyan-300/20 bg-gradient-to-br from-cyan-400/15 to-violet-400/10 p-3 text-center text-sm transition hover:-translate-y-1 hover:shadow-[0_10px_30px_rgba(34,211,238,.22)]"
|
||||
>
|
||||
{c.name}
|
||||
{c.hint && <p className="mt-1 text-xs text-slate-400">{c.hint}</p>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function VersionHistory({ app }: { app: AppResource }) {
|
||||
return (
|
||||
<div className="card-glass space-y-3">
|
||||
<h3 className="text-lg font-semibold">历史版本</h3>
|
||||
{app.versionHistory.map((v) => (
|
||||
<div key={v.version} className="flex items-center justify-between rounded-2xl border border-white/10 bg-slate-900/20 p-3 text-sm">
|
||||
<div><p>{v.version}</p><p className="text-xs text-slate-400">{v.updatedAt} · {v.size}</p></div>
|
||||
<a href={v.downloadHref} className="pill pill-active">下载此版本</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangelogTimeline({ app }: { app: AppResource }) {
|
||||
return (
|
||||
<div className="card-glass space-y-4">
|
||||
<h3 className="text-lg font-semibold">更新日志</h3>
|
||||
{app.changelog.map((item) => (
|
||||
<div key={item.date + item.title} className="relative border-l border-cyan-300/40 pl-4">
|
||||
<span className="absolute -left-[5px] top-1 h-2.5 w-2.5 rounded-full bg-cyan-300" />
|
||||
<p className="text-xs text-cyan-300">{item.date}</p>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-sm text-slate-300">{item.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QRCodeSection() {
|
||||
return (
|
||||
<div className="card-glass text-center">
|
||||
<div className="mx-auto grid h-40 w-40 place-items-center rounded-2xl border border-dashed border-cyan-300/40 bg-slate-900/70 text-xs text-slate-400">
|
||||
二维码区域
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-slate-300">关注公众号并发送关键词“下载”获取解锁码。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
components/apps-explorer.tsx
Normal file
57
components/apps-explorer.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { AppGrid, CategoryTabs } from "@/components/sections";
|
||||
import { AppResource } from "@/lib/types";
|
||||
|
||||
export function AppsExplorer({ apps }: { apps: AppResource[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [category, setCategory] = useState("all");
|
||||
const [sort, setSort] = useState("latest");
|
||||
const [count, setCount] = useState(6);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = [...apps].filter((a) =>
|
||||
[a.name, a.subtitle, ...a.tags].join(" ").toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
if (category !== "all") list = list.filter((a) => a.category === category);
|
||||
list.sort((a, b) => {
|
||||
if (sort === "hot") return b.downloads - a.downloads;
|
||||
if (sort === "update") return b.updatedAt.localeCompare(a.updatedAt);
|
||||
return b.uploadedAt.localeCompare(a.uploadedAt);
|
||||
});
|
||||
return list;
|
||||
}, [apps, query, category, sort]);
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="card-glass space-y-3">
|
||||
<input
|
||||
className="w-full rounded-xl border border-white/15 bg-slate-900/50 px-3 py-2 text-sm outline-none"
|
||||
placeholder="搜索资源、标签或名称"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<CategoryTabs active={category} onChange={setCategory} />
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span>排序:</span>
|
||||
{[
|
||||
{ id: "latest", label: "最新" },
|
||||
{ id: "hot", label: "最热" },
|
||||
{ id: "update", label: "更新日期" },
|
||||
].map((s) => (
|
||||
<button key={s.id} className={`pill ${sort === s.id ? "pill-active" : ""}`} onClick={() => setSort(s.id)}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AppGrid apps={filtered.slice(0, count)} />
|
||||
{count < filtered.length && (
|
||||
<button onClick={() => setCount((c) => c + 6)} className="mx-auto block rounded-xl border border-white/20 px-4 py-2 text-sm">
|
||||
加载更多
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
66
components/auth-provider.tsx
Normal file
66
components/auth-provider.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, PropsWithChildren, useContext, useMemo, useState } from "react";
|
||||
import { AccessLevel, SessionState } from "@/lib/types";
|
||||
|
||||
interface AuthContextValue {
|
||||
session: SessionState;
|
||||
loginAs: (name?: string) => void;
|
||||
logout: () => void;
|
||||
setAccessLevel: (level: AccessLevel) => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "download_nova_session";
|
||||
|
||||
const defaultSession: SessionState = {
|
||||
isLoggedIn: false,
|
||||
accessLevel: "guest",
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
function readSession(): SessionState {
|
||||
if (typeof window === "undefined") return defaultSession;
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return defaultSession;
|
||||
try {
|
||||
return { ...defaultSession, ...JSON.parse(raw) } as SessionState;
|
||||
} catch {
|
||||
return defaultSession;
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: PropsWithChildren) {
|
||||
const [session, setSession] = useState<SessionState>(() => readSession());
|
||||
|
||||
const persist = (next: SessionState) => {
|
||||
setSession(next);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
};
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
session,
|
||||
loginAs: (name = "Nova 用户") =>
|
||||
persist({
|
||||
...session,
|
||||
isLoggedIn: true,
|
||||
userName: name,
|
||||
accessLevel: session.accessLevel === "guest" ? "unlocked" : session.accessLevel,
|
||||
}),
|
||||
logout: () => persist(defaultSession),
|
||||
setAccessLevel: (level) => persist({ ...session, isLoggedIn: true, accessLevel: level }),
|
||||
}),
|
||||
[session],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
26
components/detail-scroll-showcase.tsx
Normal file
26
components/detail-scroll-showcase.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
|
||||
export function DetailScrollShowcase({ name }: { name: string }) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<ScrollReveal>
|
||||
<h3 className="text-lg font-semibold">{name} · 深度特性分段</h3>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.06}>
|
||||
<motion.div
|
||||
whileInView={{ backgroundPositionX: ["0%", "100%"] }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 1.6, ease: "linear" }}
|
||||
className="card-glass bg-[linear-gradient(120deg,rgba(34,211,238,.08),rgba(139,92,246,.12),rgba(34,211,238,.08))] bg-[length:220%_100%]"
|
||||
>
|
||||
<p className="text-sm text-slate-300">
|
||||
分段滚动将“下载信息 -> 历史版本 -> 更新日志 -> 解锁引导”串成连续阅读路径,提升理解与转化效率。
|
||||
</p>
|
||||
</motion.div>
|
||||
</ScrollReveal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
54
components/developer-flags-provider.tsx
Normal file
54
components/developer-flags-provider.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, PropsWithChildren, useCallback, useContext, useMemo, useState } from "react";
|
||||
|
||||
type DevFlags = {
|
||||
opsPopup: boolean;
|
||||
eventDebug: boolean;
|
||||
abBanner: boolean;
|
||||
};
|
||||
|
||||
type DevFlagsContextValue = {
|
||||
flags: DevFlags;
|
||||
setFlag: (key: keyof DevFlags, value: boolean) => void;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "download_nova_dev_flags";
|
||||
|
||||
const defaultFlags: DevFlags = {
|
||||
opsPopup: false,
|
||||
eventDebug: false,
|
||||
abBanner: false,
|
||||
};
|
||||
|
||||
const DevFlagsContext = createContext<DevFlagsContextValue | null>(null);
|
||||
|
||||
function readFlags(): DevFlags {
|
||||
if (typeof window === "undefined") return defaultFlags;
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return defaultFlags;
|
||||
try {
|
||||
return { ...defaultFlags, ...(JSON.parse(raw) as Partial<DevFlags>) };
|
||||
} catch {
|
||||
return defaultFlags;
|
||||
}
|
||||
}
|
||||
|
||||
export function DeveloperFlagsProvider({ children }: PropsWithChildren) {
|
||||
const [flags, setFlags] = useState<DevFlags>(() => readFlags());
|
||||
|
||||
const setFlag = useCallback((key: keyof DevFlags, value: boolean) => {
|
||||
const next = { ...flags, [key]: value };
|
||||
setFlags(next);
|
||||
if (typeof window !== "undefined") localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
}, [flags]);
|
||||
|
||||
const value = useMemo(() => ({ flags, setFlag }), [flags, setFlag]);
|
||||
return <DevFlagsContext.Provider value={value}>{children}</DevFlagsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useDeveloperFlags() {
|
||||
const ctx = useContext(DevFlagsContext);
|
||||
if (!ctx) throw new Error("useDeveloperFlags must be used within DeveloperFlagsProvider");
|
||||
return ctx;
|
||||
}
|
||||
49
components/event-debug-panel.tsx
Normal file
49
components/event-debug-panel.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDeveloperFlags } from "@/components/developer-flags-provider";
|
||||
|
||||
type DebugEvent = { event: string; payload: Record<string, unknown>; ts: number };
|
||||
|
||||
export function EventDebugPanel() {
|
||||
const { flags } = useDeveloperFlags();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [events, setEvents] = useState<DebugEvent[]>(() => {
|
||||
if (typeof window === "undefined") return [];
|
||||
return ((window.__DOWNLOAD_NOVA_TRACKS__ ?? []) as DebugEvent[]).slice(-20).reverse();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const custom = e as CustomEvent<DebugEvent>;
|
||||
setEvents((prev) => [custom.detail, ...prev].slice(0, 20));
|
||||
};
|
||||
window.addEventListener("download-nova-track", handler);
|
||||
return () => window.removeEventListener("download-nova-track", handler);
|
||||
}, []);
|
||||
|
||||
if (process.env.NODE_ENV === "production" || !flags.eventDebug) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[60]">
|
||||
<button className="cta-sub text-xs" onClick={() => setOpen((v) => !v)}>
|
||||
埋点调试面板
|
||||
</button>
|
||||
{open && (
|
||||
<div className="card-glass mt-2 w-[360px] max-w-[90vw] space-y-2">
|
||||
<p className="text-xs text-slate-400">最近 20 条事件</p>
|
||||
<div className="max-h-80 space-y-1 overflow-auto pr-1 text-xs">
|
||||
{events.length === 0 && <p className="text-slate-500">暂无事件</p>}
|
||||
{events.map((item, idx) => (
|
||||
<div key={`${item.ts}-${idx}`} className="rounded-lg border border-white/10 bg-slate-900/30 p-2">
|
||||
<p className="font-medium text-cyan-300">{item.event}</p>
|
||||
<p className="mt-1 text-slate-400">{new Date(item.ts).toLocaleTimeString()}</p>
|
||||
<pre className="mt-1 whitespace-pre-wrap text-slate-300">{JSON.stringify(item.payload)}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
components/hidden-easter-egg-trigger.tsx
Normal file
62
components/hidden-easter-egg-trigger.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { PropsWithChildren, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useDeveloperFlags } from "@/components/developer-flags-provider";
|
||||
|
||||
export function HiddenEasterEggTrigger({ children }: PropsWithChildren) {
|
||||
const [clickCount, setClickCount] = useState(0);
|
||||
const [show, setShow] = useState(false);
|
||||
const { flags, setFlag } = useDeveloperFlags();
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = clickCount + 1;
|
||||
setClickCount(next);
|
||||
if (next >= 10) {
|
||||
setShow(true);
|
||||
setClickCount(0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 grid place-items-center bg-slate-950/60 px-4"
|
||||
onClick={() => setShow(false)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: 12, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="w-full max-w-sm rounded-2xl border border-cyan-300/30 bg-slate-900/90 p-6 backdrop-blur-lg"
|
||||
>
|
||||
<p className="text-lg font-semibold text-cyan-300">开发者控制台</p>
|
||||
<p className="mt-2 text-sm text-slate-300">仅开发者可见的开关,不对普通用户展示。</p>
|
||||
<div className="mt-4 space-y-2 text-sm">
|
||||
<label className="flex items-center justify-between rounded-lg border border-white/10 px-3 py-2">
|
||||
运营弹层
|
||||
<input type="checkbox" checked={flags.opsPopup} onChange={(e) => setFlag("opsPopup", e.target.checked)} />
|
||||
</label>
|
||||
<label className="flex items-center justify-between rounded-lg border border-white/10 px-3 py-2">
|
||||
A/B Banner
|
||||
<input type="checkbox" checked={flags.abBanner} onChange={(e) => setFlag("abBanner", e.target.checked)} />
|
||||
</label>
|
||||
<label className="flex items-center justify-between rounded-lg border border-white/10 px-3 py-2">
|
||||
事件调试面板
|
||||
<input type="checkbox" checked={flags.eventDebug} onChange={(e) => setFlag("eventDebug", e.target.checked)} />
|
||||
</label>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
49
components/home-ab-banner.tsx
Normal file
49
components/home-ab-banner.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTrack } from "@/hooks/use-track";
|
||||
import { TRACKING_EVENTS } from "@/lib/tracking-events";
|
||||
import { useDeveloperFlags } from "@/components/developer-flags-provider";
|
||||
|
||||
const variants = [
|
||||
{ id: "A", title: "限时解锁活动", desc: "关注公众号回复“下载”,领取高级线路和社群资格。", cta: "去解锁", href: "/unlock" },
|
||||
{ id: "B", title: "VIP 试用开放", desc: "试用会员权益:高速源、历史版本包、优先更新提醒。", cta: "查看会员", href: "/pricing" },
|
||||
] as const;
|
||||
|
||||
export function HomeABBanner() {
|
||||
const { track } = useTrack();
|
||||
const { flags } = useDeveloperFlags();
|
||||
const [variantId] = useState<"A" | "B">(() => {
|
||||
if (typeof window === "undefined") return "A";
|
||||
const key = "download_nova_ab_home";
|
||||
const cached = localStorage.getItem(key) as "A" | "B" | null;
|
||||
const picked = cached ?? (Math.random() > 0.5 ? "A" : "B");
|
||||
localStorage.setItem(key, picked);
|
||||
return picked;
|
||||
});
|
||||
const variant = useMemo(() => variants.find((v) => v.id === variantId) ?? variants[0], [variantId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!flags.abBanner) return;
|
||||
track(TRACKING_EVENTS.abExpose, { placement: "home_banner", variant: variantId });
|
||||
}, [track, variantId, flags.abBanner]);
|
||||
|
||||
if (!flags.abBanner) return null;
|
||||
|
||||
return (
|
||||
<section className="card-glass relative overflow-hidden">
|
||||
<div className="absolute -right-8 -top-8 h-28 w-28 rounded-full bg-violet-300/20 blur-3xl" />
|
||||
<p className="text-xs text-violet-300">A/B Banner · Variant {variant.id}</p>
|
||||
<h3 className="mt-1 text-2xl font-semibold">{variant.title}</h3>
|
||||
<p className="mt-2 max-w-2xl text-sm text-slate-300">{variant.desc}</p>
|
||||
<Link
|
||||
href={variant.href}
|
||||
onClick={() => track(TRACKING_EVENTS.abClick, { placement: "home_banner", variant: variant.id })}
|
||||
className="cta-main mt-4 inline-flex"
|
||||
>
|
||||
{variant.cta}
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
47
components/home-scroll-story.tsx
Normal file
47
components/home-scroll-story.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ScrollReveal } from "@/components/scroll-reveal";
|
||||
|
||||
const story = [
|
||||
{
|
||||
title: "发现优质资源",
|
||||
desc: "搜索、分类与推荐协同,让用户 3 秒定位所需软件。",
|
||||
badge: "Discover",
|
||||
},
|
||||
{
|
||||
title: "透明化下载细节",
|
||||
desc: "版本、大小、更新日期、修改日志与安全标识完整可视化。",
|
||||
badge: "Trust",
|
||||
},
|
||||
{
|
||||
title: "自然引导转化",
|
||||
desc: "公众号解锁、社群、VIP 与付费能力以产品化方式融入。",
|
||||
badge: "Convert",
|
||||
},
|
||||
];
|
||||
|
||||
export function HomeScrollStory() {
|
||||
return (
|
||||
<section className="space-y-4 py-2">
|
||||
<ScrollReveal>
|
||||
<h2 className="text-xl font-semibold">滚动叙事体验</h2>
|
||||
</ScrollReveal>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{story.map((item, idx) => (
|
||||
<ScrollReveal key={item.title} delay={idx * 0.08}>
|
||||
<motion.article
|
||||
whileHover={{ y: -8, scale: 1.01 }}
|
||||
className="card-glass relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute -right-5 -top-5 h-24 w-24 rounded-full bg-cyan-300/20 blur-2xl" />
|
||||
<p className="text-xs uppercase tracking-wider text-cyan-300">{item.badge}</p>
|
||||
<h3 className="mt-2 text-lg font-semibold">{item.title}</h3>
|
||||
<p className="mt-2 text-sm text-slate-300">{item.desc}</p>
|
||||
</motion.article>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
25
components/insight-chart.tsx
Normal file
25
components/insight-chart.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import { insightTrend } from "@/lib/mock-data";
|
||||
import { baseGrid, chartTheme } from "@/lib/echarts-theme";
|
||||
|
||||
export function InsightChart() {
|
||||
const option = {
|
||||
textStyle: { color: chartTheme.text },
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: baseGrid(),
|
||||
xAxis: { type: "category", data: insightTrend.map((i) => i.date), axisLabel: { color: chartTheme.axis } },
|
||||
yAxis: { type: "value", axisLabel: { color: chartTheme.axis }, splitLine: { lineStyle: { color: chartTheme.split } } },
|
||||
series: [
|
||||
{
|
||||
data: insightTrend.map((i) => i.downloads),
|
||||
type: "line",
|
||||
smooth: true,
|
||||
lineStyle: { color: chartTheme.line, width: 3 },
|
||||
areaStyle: { color: "rgba(34,211,238,.2)" },
|
||||
},
|
||||
],
|
||||
};
|
||||
return <ReactECharts option={option} style={{ height: 320 }} />;
|
||||
}
|
||||
53
components/insight-extra-charts.tsx
Normal file
53
components/insight-extra-charts.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import { baseGrid, chartTheme } from "@/lib/echarts-theme";
|
||||
|
||||
export function InsightBarChart() {
|
||||
const option = {
|
||||
textStyle: { color: chartTheme.text },
|
||||
tooltip: { trigger: "axis" },
|
||||
grid: baseGrid(),
|
||||
xAxis: {
|
||||
type: "category",
|
||||
axisLabel: { color: chartTheme.axis },
|
||||
data: ["AI 工具", "媒体工具", "设计工具", "开发工具", "网络工具"],
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
axisLabel: { color: chartTheme.axis },
|
||||
splitLine: { lineStyle: { color: chartTheme.split } },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "bar",
|
||||
data: [34, 24, 18, 14, 10],
|
||||
itemStyle: { borderRadius: [8, 8, 0, 0], color: chartTheme.bar },
|
||||
},
|
||||
],
|
||||
};
|
||||
return <ReactECharts option={option} style={{ height: 280 }} />;
|
||||
}
|
||||
|
||||
export function InsightPieChart() {
|
||||
const option = {
|
||||
textStyle: { color: chartTheme.text },
|
||||
tooltip: { trigger: "item" },
|
||||
legend: { bottom: 0, textStyle: { color: chartTheme.subText } },
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["45%", "72%"],
|
||||
itemStyle: { borderRadius: 8, borderColor: "#020617", borderWidth: 2 },
|
||||
label: { color: chartTheme.text },
|
||||
data: [
|
||||
{ value: 42, name: "自然搜索", itemStyle: { color: chartTheme.pieA } },
|
||||
{ value: 27, name: "公众号", itemStyle: { color: chartTheme.pieB } },
|
||||
{ value: 16, name: "社群", itemStyle: { color: chartTheme.pieC } },
|
||||
{ value: 15, name: "外链", itemStyle: { color: chartTheme.pieD } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
return <ReactECharts option={option} style={{ height: 280 }} />;
|
||||
}
|
||||
48
components/navbar.tsx
Normal file
48
components/navbar.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Search, Sparkles } from "lucide-react";
|
||||
import { HiddenEasterEggTrigger } from "@/components/hidden-easter-egg-trigger";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
|
||||
export function Navbar() {
|
||||
const { session, loginAs, logout } = useAuth();
|
||||
return (
|
||||
<motion.header
|
||||
initial={{ y: -24, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
className="sticky top-0 z-40 border-b border-white/10 bg-slate-950/70 backdrop-blur-xl"
|
||||
>
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 md:px-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<HiddenEasterEggTrigger>
|
||||
<Link href="/" className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Sparkles className="text-cyan-400" size={18} />
|
||||
Download Nova
|
||||
</Link>
|
||||
</HiddenEasterEggTrigger>
|
||||
<nav className="hidden items-center gap-4 text-sm text-slate-300 md:flex">
|
||||
<Link href="/apps">资源库</Link>
|
||||
<Link href="/insights">数据洞察</Link>
|
||||
<Link href="/unlock">解锁中心</Link>
|
||||
<Link href="/pricing">会员套餐</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="pill hidden md:inline-flex" onClick={session.isLoggedIn ? logout : () => loginAs()}>
|
||||
{session.isLoggedIn ? `${session.userName ?? "用户"} · ${session.accessLevel}` : "未登录"}
|
||||
</button>
|
||||
<Link href="/apps" className="rounded-full border border-white/20 p-2">
|
||||
<Search size={16} />
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
<Link href="/pricing" className="rounded-full bg-cyan-400/90 px-4 py-1.5 text-xs font-semibold text-slate-900 shadow-[0_0_20px_rgba(34,211,238,0.45)]">
|
||||
登录 / 会员
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</motion.header>
|
||||
);
|
||||
}
|
||||
72
components/ops-popup.tsx
Normal file
72
components/ops-popup.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useTrack } from "@/hooks/use-track";
|
||||
import { TRACKING_EVENTS } from "@/lib/tracking-events";
|
||||
import { useDeveloperFlags } from "@/components/developer-flags-provider";
|
||||
|
||||
const STORAGE_KEY = "download_nova_ops_popup_closed";
|
||||
|
||||
export function OpsPopup() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { track } = useTrack();
|
||||
const { flags } = useDeveloperFlags();
|
||||
|
||||
useEffect(() => {
|
||||
if (!flags.opsPopup) return;
|
||||
const closed = window.localStorage.getItem(STORAGE_KEY) === "1";
|
||||
if (!closed) {
|
||||
const timer = window.setTimeout(() => {
|
||||
setOpen(true);
|
||||
track(TRACKING_EVENTS.popupExpose, { scene: "global_ops_popup" });
|
||||
}, 1500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [track, flags.opsPopup]);
|
||||
|
||||
if (!flags.opsPopup) return null;
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
window.localStorage.setItem(STORAGE_KEY, "1");
|
||||
track(TRACKING_EVENTS.popupClose, { scene: "global_ops_popup" });
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 grid place-items-end bg-slate-950/45 p-4 md:place-items-center"
|
||||
onClick={close}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 16, opacity: 0 }}
|
||||
className="card-glass w-full max-w-md"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="text-xs text-cyan-300">限时运营活动</p>
|
||||
<h3 className="mt-1 text-xl font-semibold">关注公众号领取专属解锁码</h3>
|
||||
<p className="mt-2 text-sm text-slate-300">输入关键词“下载”,可获取新资源首发、加群入口与 VIP 试用资格。</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Link
|
||||
href="/unlock"
|
||||
onClick={() => track(TRACKING_EVENTS.popupClick, { target: "unlock", scene: "global_ops_popup" })}
|
||||
className="cta-main"
|
||||
>
|
||||
立即领取
|
||||
</Link>
|
||||
<button className="cta-sub" onClick={close}>稍后再说</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
22
components/order-activation.tsx
Normal file
22
components/order-activation.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
|
||||
export function OrderActivation({ slug, canActivate }: { slug: string; canActivate: boolean }) {
|
||||
const { setAccessLevel } = useAuth();
|
||||
|
||||
if (!canActivate) {
|
||||
return <Link href={`/apps/${slug}`} className="cta-main">返回资源详情</Link>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/apps/${slug}`}
|
||||
onClick={() => setAccessLevel("vip")}
|
||||
className="cta-main"
|
||||
>
|
||||
激活 VIP 并返回资源详情
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
70
components/screenshot-gallery.tsx
Normal file
70
components/screenshot-gallery.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTrack } from "@/hooks/use-track";
|
||||
import { TRACKING_EVENTS } from "@/lib/tracking-events";
|
||||
|
||||
export function ScreenshotGallery({ slug, shots }: { slug: string; shots: string[] }) {
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
const { track } = useTrack();
|
||||
|
||||
const open = (idx: number) => {
|
||||
setActive(idx);
|
||||
track(TRACKING_EVENTS.galleryOpen, { slug, index: idx });
|
||||
};
|
||||
|
||||
const move = (delta: number) => {
|
||||
setActive((prev) => {
|
||||
if (prev === null) return 0;
|
||||
const next = (prev + delta + shots.length) % shots.length;
|
||||
track(TRACKING_EVENTS.galleryNavigate, { slug, from: prev, to: next });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
{shots.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => open(idx)}
|
||||
className="card-glass group relative grid h-40 place-items-center overflow-hidden text-sm text-slate-300"
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-cyan-300/20 via-transparent to-violet-300/20 opacity-70 transition group-hover:opacity-100" />
|
||||
截图预览 {idx + 1}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
<AnimatePresence>
|
||||
{active !== null && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 grid place-items-center bg-slate-950/78 p-4"
|
||||
onClick={() => setActive(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.98, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.98, opacity: 0 }}
|
||||
className="card-glass w-full max-w-3xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="grid h-[58vh] place-items-center rounded-2xl border border-white/10 bg-slate-900/40">
|
||||
<p className="text-lg text-slate-200">截图大图 {active + 1}</p>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<button className="cta-sub" onClick={() => move(-1)}>上一张</button>
|
||||
<p className="text-xs text-slate-400">{active + 1} / {shots.length}</p>
|
||||
<button className="cta-sub" onClick={() => move(1)}>下一张</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
components/scroll-reveal.tsx
Normal file
22
components/scroll-reveal.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export function ScrollReveal({
|
||||
children,
|
||||
delay = 0,
|
||||
y = 24,
|
||||
}: PropsWithChildren<{ delay?: number; y?: number }>) {
|
||||
return (
|
||||
<motion.div
|
||||
className="w-full min-w-0 max-w-full"
|
||||
initial={{ opacity: 0, y }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={{ duration: 0.55, ease: "easeOut", delay }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
157
components/sections.tsx
Normal file
157
components/sections.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { categoryLabels, hotKeywords } from "@/lib/mock-data";
|
||||
import { AppResource } from "@/lib/types";
|
||||
import { BadgeCheck, ShieldCheck, Download, ArrowRight } from "lucide-react";
|
||||
import { buildChannelUrl } from "@/lib/marketing";
|
||||
import { useTrack } from "@/hooks/use-track";
|
||||
import { TRACKING_EVENTS } from "@/lib/tracking-events";
|
||||
|
||||
export function SearchBar({ placeholder = "搜索资源..." }: { placeholder?: string }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-cyan-300/20 bg-slate-950/30 p-2 backdrop-blur-xl">
|
||||
<input
|
||||
placeholder={placeholder}
|
||||
className="input-fancy border-none bg-transparent py-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CategoryTabs({
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
active: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => onChange("all")} className={`pill ${active === "all" ? "pill-active" : ""}`}>全部</button>
|
||||
{Object.entries(categoryLabels).map(([key, label]) => (
|
||||
<button key={key} onClick={() => onChange(key)} className={`pill ${active === key ? "pill-active" : ""}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroSection() {
|
||||
const { track } = useTrack();
|
||||
return (
|
||||
<motion.section initial={{ opacity: 0, y: 24 }} animate={{ opacity: 1, y: 0 }} className="w-full min-w-0 space-y-4 py-4">
|
||||
<h1 className="text-3xl font-bold leading-tight tracking-tight md:text-4xl">找软件,直接下载</h1>
|
||||
<p className="max-w-2xl text-sm text-slate-400">每日更新,版本清晰,来源可查。</p>
|
||||
<SearchBar placeholder="试试搜索:AI 绘图、剪辑、开发工具..." />
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{hotKeywords.map((k) => (
|
||||
<span key={k} className="pill">{k}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/apps" onClick={() => track(TRACKING_EVENTS.ctaClick, { position: "hero", target: "apps" })} className="cta-main">进入资源库</Link>
|
||||
<Link href="/unlock" onClick={() => track(TRACKING_EVENTS.ctaClick, { position: "hero", target: "unlock" })} className="cta-sub">输入解锁码</Link>
|
||||
</div>
|
||||
</motion.section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SafetyBadge({ items }: { items: AppResource["safety"] }) {
|
||||
return (
|
||||
<div className="flex gap-1 text-emerald-300">
|
||||
{items.includes("official") && <BadgeCheck size={14} />}
|
||||
{items.includes("verified") && <ShieldCheck size={14} />}
|
||||
{items.includes("clean") && <ShieldCheck size={14} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppCard({ app }: { app: AppResource }) {
|
||||
const { track } = useTrack();
|
||||
const primaryChannel = app.channels[0];
|
||||
const downloadUrl = buildChannelUrl(primaryChannel?.href ?? "#", primaryChannel?.name ?? "direct", app.slug);
|
||||
return (
|
||||
<motion.article whileHover={{ y: -4 }} className="card-glass flex h-full flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xl">{app.icon}</span>
|
||||
<SafetyBadge items={app.safety} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">{app.name}</h3>
|
||||
<p className="text-xs text-slate-400">{app.subtitle}</p>
|
||||
</div>
|
||||
<p className="line-clamp-2 text-xs text-slate-300">{app.description}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{app.tags.map((tag) => (
|
||||
<span key={tag} className="pill text-[11px]">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto flex items-center justify-between text-[11px] text-slate-400">
|
||||
<span>{app.version}</span>
|
||||
<span>{app.size}</span>
|
||||
<span>{app.updatedAt}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href={`/apps/${app.slug}`} className="text-sm text-cyan-300">查看详情</Link>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
onClick={() => track(TRACKING_EVENTS.downloadClick, { slug: app.slug, channel: primaryChannel?.name ?? "direct", location: "card" })}
|
||||
className="inline-flex items-center gap-1 rounded-xl bg-cyan-400/90 px-3.5 py-2 text-xs font-semibold text-slate-900"
|
||||
>
|
||||
下载 <Download size={12} />
|
||||
</a>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppGrid({ apps }: { apps: AppResource[] }) {
|
||||
return <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">{apps.map((a) => <AppCard key={a.id} app={a} />)}</div>;
|
||||
}
|
||||
|
||||
export function StatsPanel() {
|
||||
const stats = [
|
||||
{ k: "资源总数", v: "1,286" },
|
||||
{ k: "今日更新", v: "27" },
|
||||
{ k: "总下载量", v: "8.4M" },
|
||||
{ k: "热门分类", v: "AI 工具" },
|
||||
];
|
||||
return (
|
||||
<section className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
{stats.map((s) => (
|
||||
<motion.div key={s.k} initial={{ opacity: 0, y: 10 }} whileInView={{ opacity: 1, y: 0 }} className="card-glass">
|
||||
<p className="text-xs text-slate-400">{s.k}</p>
|
||||
<p className="mt-2 text-2xl font-bold text-cyan-300">{s.v}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function UnlockSection() {
|
||||
return (
|
||||
<section className="card-glass flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold">更多下载方式</h3>
|
||||
<p className="mt-1 text-sm text-slate-300">关注公众号可获取解锁码,会员可用更多下载线路。</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
<Link href="/unlock" className="cta-main inline-flex items-center justify-center gap-1 whitespace-nowrap">
|
||||
去解锁中心 <ArrowRight size={14} />
|
||||
</Link>
|
||||
<Link href="/pricing" className="cta-sub inline-flex justify-center whitespace-nowrap">查看会员</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="mt-16 border-t border-white/10 py-8 text-center text-xs text-slate-400">
|
||||
<p>Download Nova · 资源下载站</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
12
components/theme-provider.tsx
Normal file
12
components/theme-provider.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemeProvider } from "next-themes";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export function ThemeProvider({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<NextThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{children}
|
||||
</NextThemeProvider>
|
||||
);
|
||||
}
|
||||
25
components/theme-toggle.tsx
Normal file
25
components/theme-toggle.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const dark = resolvedTheme === "dark";
|
||||
return (
|
||||
<button
|
||||
className="rounded-full border border-white/20 bg-white/10 p-2 backdrop-blur-md"
|
||||
onClick={() => setTheme(dark ? "light" : "dark")}
|
||||
aria-label="切换主题"
|
||||
>
|
||||
<motion.div
|
||||
key={dark ? "dark" : "light"}
|
||||
initial={{ rotate: -20, opacity: 0 }}
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
>
|
||||
{dark ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</motion.div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
53
components/unlock-workflow.tsx
Normal file
53
components/unlock-workflow.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTrack } from "@/hooks/use-track";
|
||||
import { TRACKING_EVENTS } from "@/lib/tracking-events";
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
|
||||
export function UnlockWorkflow() {
|
||||
const [code, setCode] = useState("");
|
||||
const [step, setStep] = useState<"idle" | "checking" | "valid" | "invalid">("idle");
|
||||
const { track } = useTrack();
|
||||
const { setAccessLevel } = useAuth();
|
||||
|
||||
const hint = useMemo(() => {
|
||||
if (step === "checking") return "正在校验解锁码...";
|
||||
if (step === "valid") return "验证成功,已解锁高级下载入口(模拟)。";
|
||||
if (step === "invalid") return "解锁码无效,请关注公众号回复“下载”获取。";
|
||||
return "输入公众号返回的解锁码,体验后端接入前的完整交互流程。";
|
||||
}, [step]);
|
||||
|
||||
const verify = () => {
|
||||
setStep("checking");
|
||||
track(TRACKING_EVENTS.unlockSubmit, { codeLength: code.length });
|
||||
setTimeout(() => {
|
||||
const pass = code.trim().toUpperCase() === "DOWNLOAD2026";
|
||||
setStep(pass ? "valid" : "invalid");
|
||||
track(TRACKING_EVENTS.unlockResult, { success: pass });
|
||||
if (pass) setAccessLevel("unlocked");
|
||||
}, 600);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card-glass space-y-4">
|
||||
<h3 className="text-lg font-semibold">解锁码验证(前端状态机)</h3>
|
||||
<div className="flex flex-col gap-3 md:flex-row">
|
||||
<input
|
||||
className="input-fancy"
|
||||
placeholder="请输入解锁码,例如 DOWNLOAD2026"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
<button onClick={verify} className="cta-main whitespace-nowrap">
|
||||
立即验证
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300">{hint}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className="cta-sub" onClick={() => track(TRACKING_EVENTS.ctaClick, { location: "unlock", target: "group" })}>加入交流群</button>
|
||||
<button className="cta-sub" onClick={() => track(TRACKING_EVENTS.ctaClick, { location: "unlock", target: "vip" })}>开通 VIP(预留)</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user