This commit is contained in:
eric
2026-04-02 07:15:42 -05:00
parent 644a6ac3a9
commit d401a36b71
62 changed files with 13264 additions and 78 deletions

View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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;
}

View 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">
-&gt; -&gt; -&gt;
</p>
</motion.div>
</ScrollReveal>
</section>
);
}

View 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;
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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 }} />;
}

View 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
View 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
View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}