Files
gitlab-instance-0a899031_di…/app/course/page.tsx
2026-03-08 01:10:31 -06:00

571 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import { VideoCard } from "./VideoCard";
import { useVideoProgress } from "./useVideoProgress";
import AuthModal from "../components/AuthModal";
function getStoredUser(): string | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem("pb_user");
if (!raw) return null;
const user = JSON.parse(raw);
return user?.email ?? null;
} catch {
return null;
}
}
/* ─── data ─── */
const stats = [
{ value: "21", label: "节实战课程" },
{ value: "150+", label: "分钟视频" },
{ value: "3", label: "位实战讲师" },
];
const audiences = [
{
icon: "💼",
title: "职场人想转型",
desc: "受够了 996学会远程工作技能平滑过渡到自由生活方式",
},
{
icon: "👨‍💻",
title: "开发者 / 设计师",
desc: "你的技能天然适合远程,学会如何接全球客户、拿美金报酬",
},
{
icon: "📱",
title: "自媒体 / 内容创作者",
desc: "边旅行边创作,掌握数字游民内容变现的全套方法论",
},
{
icon: "🌍",
title: "想出国旅居的人",
desc: "签证、税务、保险、目的地一站搞定,少走弯路直接出发",
},
];
const benefits = [
{
num: 1,
title: "完整的远程工作体系",
desc: "从技能评估到客户获取,建立可持续的远程收入系统",
},
{
num: 2,
title: "打通 3+ 收入渠道",
desc: "远程全职、自由职业、数字产品、内容创作多管齐下",
},
{
num: 3,
title: "掌握 50+ 远程工具",
desc: "协作、会议、项目管理、财务,一个背包装下整个办公室",
},
{
num: 4,
title: "签证 + 税务 + 保险全通关",
desc: "55 国数字游民签证政策、合规税务方案、保险选购指南",
},
{
num: 5,
title: "跑通第一次数字游民旅程",
desc: "从选目的地到打包出发,手把手带你完成首次数字游民体验",
},
];
// 示例视频w3schools 公开样本,可替换为 MinIO CDN URL如 https://minioweb.hackrobot.cn/hackrobot/course/xxx.mp4
const SAMPLE_VIDEO = "https://www.w3schools.com/html/mov_bbb.mp4";
// 一级part 二级section 三级lesson
const curriculum = [
{
emoji: "🚀",
part: "基础篇",
sections: [
{ name: "认识数字游民", lessons: [{ id: "01", title: "什么是数字游民?破除 5 个常见误解", duration: "12:30", videoUrl: SAMPLE_VIDEO }, { id: "02", title: "数字游民三种模式:蜜蜂、乌龟、候鸟", duration: "08:45", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "💡",
part: "技能篇",
sections: [
{ name: "远程工作能力", lessons: [{ id: "03", title: "远程高薪技能全景图:找到你的方向", duration: "14:20", videoUrl: SAMPLE_VIDEO }, { id: "04", title: "30天技能升级路线从评估到接单", duration: "11:15", videoUrl: SAMPLE_VIDEO }, { id: "05", title: "建立个人品牌:让客户主动找你", duration: "09:40", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "💰",
part: "收入篇",
sections: [
{ name: "构建收入体系", lessons: [{ id: "06", title: "远程全职 vs 自由职业:选哪条路?", duration: "10:05", videoUrl: SAMPLE_VIDEO }, { id: "07", title: "自由职业平台实操Upwork / Toptal / 电鸭", duration: "13:50", videoUrl: SAMPLE_VIDEO }, { id: "08", title: "被动收入搭建:数字产品 & 在线课程", duration: "12:20", videoUrl: SAMPLE_VIDEO }, { id: "09", title: "定价的艺术:如何报价不心虚", duration: "07:30", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "🛠️",
part: "工具篇",
sections: [
{ name: "移动办公装备", lessons: [{ id: "10", title: "硬件极简主义:一个背包装下办公室", duration: "06:45", videoUrl: SAMPLE_VIDEO }, { id: "11", title: "远程协作工具栈Slack / Notion / Linear", duration: "08:30", videoUrl: SAMPLE_VIDEO }, { id: "12", title: "安全必修课VPN / 密码管理 / 2FA", duration: "05:55", videoUrl: SAMPLE_VIDEO }, { id: "13", title: "跨境财务工具Wise / Stripe / Revolut", duration: "07:10", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "📋",
part: "合规篇",
sections: [
{ name: "签证税务保险", lessons: [{ id: "14", title: "55 国数字游民签证政策全解读", duration: "15:30", videoUrl: SAMPLE_VIDEO }, { id: "15", title: "税务居民身份与合规规划", duration: "11:40", videoUrl: SAMPLE_VIDEO }, { id: "16", title: "数字游民保险选购指南", duration: "06:20", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "🗺️",
part: "目的地篇",
sections: [
{ name: "选择你的城市", lessons: [{ id: "17", title: "亚洲三城:清迈 / 巴厘岛 / 首尔深度对比", duration: "10:50", videoUrl: SAMPLE_VIDEO }, { id: "18", title: "欧洲双雄:里斯本 / 巴塞罗那生活实录", duration: "09:25", videoUrl: SAMPLE_VIDEO }, { id: "19", title: "美洲探索:墨西哥城 / 麦德林旅居指南", duration: "08:15", videoUrl: SAMPLE_VIDEO }] },
],
},
{
emoji: "🏆",
part: "实战篇",
sections: [
{ name: "启程出发", lessons: [{ id: "20", title: "出发前检查清单 & 打包哲学", duration: "07:40", videoUrl: SAMPLE_VIDEO }, { id: "21", title: "第一个月生存指南 & 长期可持续策略", duration: "13:10", videoUrl: SAMPLE_VIDEO }] },
],
},
];
const instructors = [
{
name: "Alex",
avatar: "🧑‍💻",
role: "全栈开发者 / 5年数字游民",
tag: "技术导师",
desc: "前字节跳动高级工程师2021年开始数字游民生活足迹遍布 20+ 国家,远程服务硅谷客户",
},
{
name: "Lisa",
avatar: "👩‍🎨",
role: "品牌设计师 / 自由职业教练",
tag: "变现专家",
desc: "Toptal 认证设计师,年收入 $120K+,帮助 500+ 人成功转型远程自由职业",
},
{
name: "Marco",
avatar: "🧳",
role: "旅居作家 / 签证顾问",
tag: "旅居达人",
desc: "常驻里斯本,持有 3 国数字游民签证,《数字游民签证手册》作者,深谙各国政策",
},
];
/* ─── component ─── */
export default function CoursePage() {
const [modalOpen, setModalOpen] = useState(false);
const [authOpen, setAuthOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [expandedPart, setExpandedPart] = useState<string | null>(null);
const { markCompleted, isCompleted, isPartCompleted } = useVideoProgress();
useEffect(() => {
setUserEmail(getStoredUser());
}, []);
const handleLogout = () => {
localStorage.removeItem("pb_token");
localStorage.removeItem("pb_user");
setUserEmail(null);
};
return (
<div className="min-h-screen bg-white text-slate-900">
{/* ── Nav ── */}
<header className="sticky top-0 z-50 border-b border-slate-100 bg-white/80 backdrop-blur-lg">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-3 px-4 sm:px-6">
<a href="/" className="flex items-center gap-2 text-sm font-bold">
<span className="text-lg">🌍</span>
</a>
<div className="flex items-center gap-2 sm:gap-3">
{userEmail ? (
<>
<span className="hidden max-w-[120px] truncate text-sm text-slate-600 sm:inline">
{userEmail}
</span>
<button
onClick={handleLogout}
className="rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50"
>
退
</button>
</>
) : (
<button
onClick={() => setAuthOpen(true)}
className="rounded-full border border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50"
>
/
</button>
)}
<button
onClick={() => setModalOpen(true)}
className="rounded-full bg-sky-500 px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-sky-600"
>
</button>
</div>
</div>
</header>
{/* ── Hero ── */}
<section className="relative overflow-hidden bg-gradient-to-b from-slate-50 to-white py-20 sm:py-28">
<div className="pointer-events-none absolute top-0 left-1/2 h-[500px] w-[700px] -translate-x-1/2 rounded-full bg-gradient-to-br from-sky-100/50 via-cyan-50/30 to-amber-50/20 blur-3xl" />
<div className="relative mx-auto max-w-3xl px-4 text-center sm:px-6">
<h1 className="text-4xl font-extrabold tracking-tight sm:text-5xl lg:text-6xl">
<span className="gradient-text"></span>
</h1>
<p className="mx-auto mt-5 max-w-xl text-lg leading-relaxed text-slate-600 sm:text-xl">
7
</p>
<div className="mt-10 flex items-center justify-center gap-6 sm:gap-10">
{stats.map((s) => (
<div key={s.label} className="text-center">
<div className="text-3xl font-extrabold text-sky-600 sm:text-4xl">
{s.value}
</div>
<div className="mt-1 text-sm text-slate-500">{s.label}</div>
</div>
))}
</div>
<button
onClick={() => setModalOpen(true)}
className="mt-10 inline-flex items-center gap-2 rounded-full bg-sky-500 px-10 py-4 text-lg font-semibold text-white shadow-lg shadow-sky-500/25 transition-all hover:bg-sky-600 hover:shadow-xl hover:shadow-sky-500/30"
>
<span></span>
</button>
</div>
</section>
{/* ── Audience ── */}
<section className="py-20 sm:py-24">
<div className="mx-auto max-w-5xl px-4 sm:px-6">
<h2 className="text-center text-2xl font-bold sm:text-3xl">
</h2>
<p className="mx-auto mt-3 max-w-xl text-center text-slate-500">
</p>
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{audiences.map((a) => (
<div
key={a.title}
className="card-hover rounded-2xl border border-slate-100 bg-white p-6 text-center shadow-sm"
>
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-sky-50 text-3xl">
{a.icon}
</div>
<h3 className="mb-2 font-bold">{a.title}</h3>
<p className="text-sm leading-relaxed text-slate-500">
{a.desc}
</p>
</div>
))}
</div>
</div>
</section>
{/* ── Benefits ── */}
<section className="bg-slate-50 py-20 sm:py-24">
<div className="mx-auto max-w-5xl px-4 sm:px-6">
<h2 className="text-center text-2xl font-bold sm:text-3xl">
</h2>
<p className="mx-auto mt-3 max-w-xl text-center text-slate-500">
5
</p>
<div className="mt-14 space-y-5">
{benefits.map((b) => (
<div
key={b.num}
className="card-hover flex items-start gap-5 rounded-2xl border border-slate-100 bg-white p-6 shadow-sm sm:items-center"
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-sky-500 to-cyan-500 text-xl font-extrabold text-white shadow-md shadow-sky-500/20">
{b.num}
</div>
<div>
<h3 className="text-lg font-bold text-slate-900">{b.title}</h3>
<p className="mt-1 text-sm text-slate-500">{b.desc}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* ── Curriculum ── 参考:垂直时间线 + 学习路径视觉 */}
<section className="relative overflow-hidden bg-gradient-to-b from-white via-slate-50/50 to-slate-50 py-24 sm:py-28">
<div className="mx-auto max-w-4xl px-4 sm:px-6">
{/* 标题区:大数字 + 学习路径感 */}
<div className="mb-16 text-center">
<div className="inline-flex items-baseline gap-2">
<span className="gradient-text text-6xl font-extrabold tabular-nums tracking-tight sm:text-7xl">
21
</span>
<span className="text-2xl font-bold text-slate-800 sm:text-3xl">
</span>
</div>
<p className="mt-4 text-slate-500">
·
</p>
<div className="mx-auto mt-6 h-px w-24 rounded-full bg-gradient-to-r from-transparent via-sky-300/60 to-transparent" />
</div>
{/* 垂直时间线:左侧竖线 + 节点 */}
<div className="relative">
{/* 中央竖线 */}
<div
className="absolute left-[19px] top-6 bottom-6 w-0.5 bg-gradient-to-b from-sky-200/80 via-sky-300/60 to-sky-200/80 sm:left-[23px]"
aria-hidden
/>
<div className="space-y-0">
{curriculum.map((ch, idx) => {
const allLessons = ch.sections.flatMap((s) => s.lessons);
const partCompleted = isPartCompleted(allLessons.map((l) => l.id));
const isExpanded = expandedPart === ch.part;
const totalLessons = allLessons.length;
return (
<div key={ch.part} className="relative flex gap-6 pb-10 last:pb-0">
{/* 左侧节点圆:完成时显示 ✓ */}
<div className="relative z-10 flex shrink-0 items-start pt-0.5">
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-bold shadow-sm transition-colors sm:h-12 sm:w-12 ${
partCompleted
? "border-2 border-emerald-300 bg-emerald-50 text-emerald-600"
: "border-2 border-sky-200 bg-white text-sky-600"
}`}
>
{partCompleted ? (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
idx + 1
)}
</div>
</div>
{/* 右侧内容卡片 */}
<div className="min-w-0 flex-1">
<div
className={`overflow-hidden rounded-2xl border shadow-sm transition-colors ${
partCompleted ? "border-emerald-200/80 bg-emerald-50/30" : "border-slate-200/80 bg-white"
}`}
>
{/* 一级Part 标题,点击展开/收起 */}
<button
type="button"
onClick={() => setExpandedPart(isExpanded ? null : ch.part)}
className="flex w-full items-center justify-between gap-3 px-5 py-4 text-left transition-colors hover:bg-slate-50/80 sm:px-6"
>
<div className="flex items-center gap-3">
<span className="text-2xl">{ch.emoji}</span>
<h3 className="font-bold text-slate-800">{ch.part}</h3>
{partCompleted && (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
</span>
)}
</div>
<span className="flex items-center gap-2 text-sm text-slate-400">
<span>{totalLessons} </span>
<svg
className={`h-4 w-4 shrink-0 transition-transform duration-200 ${
isExpanded ? "rotate-180" : ""
}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</span>
</button>
{/* 展开后:二级 section + 三级 lesson */}
{isExpanded && (
<div className="border-t border-slate-100 bg-slate-50/40">
{ch.sections.map((sec) => (
<div key={sec.name} className="border-b border-slate-100 last:border-b-0">
{/* 二级标题 */}
<div className="flex items-center gap-3 border-b border-slate-100/80 bg-slate-50/60 px-5 py-3.5 sm:px-6">
<span className="flex h-6 w-1 shrink-0 rounded-full bg-gradient-to-b from-sky-400 to-sky-500" />
<h4 className="text-sm font-semibold text-slate-700">
{sec.name}
</h4>
<span className="rounded-full bg-slate-200/60 px-2 py-0.5 text-xs font-medium text-slate-500">
{sec.lessons.length}
</span>
</div>
{/* 三级:视频卡片网格 */}
<div className="grid gap-5 p-5 sm:grid-cols-2 sm:p-6 lg:grid-cols-3">
{sec.lessons.map((lesson) => (
<VideoCard
key={lesson.id}
lesson={lesson as { id: string; title: string; duration: string; videoUrl?: string }}
completed={isCompleted(lesson.id)}
onComplete={() => markCompleted(lesson.id)}
/>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</section>
{/* ── Instructors ── */}
<section className="bg-slate-50 py-20 sm:py-24">
<div className="mx-auto max-w-5xl px-4 sm:px-6">
<h2 className="text-center text-2xl font-bold sm:text-3xl">
</h2>
<p className="mx-auto mt-3 max-w-xl text-center text-slate-500">
</p>
<div className="mt-14 grid gap-6 sm:grid-cols-3">
{instructors.map((ins) => (
<div
key={ins.name}
className="card-hover rounded-2xl border border-slate-100 bg-white p-6 text-center shadow-sm"
>
<div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-sky-100 to-cyan-50 text-5xl">
{ins.avatar}
</div>
<h3 className="text-lg font-bold">{ins.name}</h3>
<p className="mt-1 text-sm text-slate-500">{ins.role}</p>
<span className="mt-3 inline-block rounded-full bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-600">
{ins.tag}
</span>
<p className="mt-4 text-sm leading-relaxed text-slate-500">
{ins.desc}
</p>
</div>
))}
</div>
</div>
</section>
{/* ── Final CTA ── */}
<section className="py-20 sm:py-28">
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6">
<h2 className="text-2xl font-bold sm:text-3xl">
</h2>
<p className="mx-auto mt-4 max-w-lg text-slate-500">
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-4 text-sm text-slate-500">
<span className="rounded-full border border-slate-200 bg-white px-4 py-1.5">
21
</span>
<span className="rounded-full border border-slate-200 bg-white px-4 py-1.5">
</span>
<span className="rounded-full border border-slate-200 bg-white px-4 py-1.5">
</span>
</div>
<button
onClick={() => setModalOpen(true)}
className="mt-10 inline-flex items-center gap-2 rounded-full bg-sky-500 px-10 py-4 text-lg font-semibold text-white shadow-lg shadow-sky-500/25 transition-all hover:bg-sky-600 hover:shadow-xl hover:shadow-sky-500/30"
>
<span></span>
</button>
</div>
</section>
{/* ── Footer ── */}
<footer className="border-t border-slate-100 bg-white py-8 text-center text-sm text-slate-400">
<a href="/" className="font-medium text-slate-600 transition-colors hover:text-sky-600">
🌍
</a>
<span className="mx-2">·</span>
·
</footer>
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={(email) => setUserEmail(email)}
/>
{/* ── 报名 Modal ── */}
{modalOpen && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
onClick={() => setModalOpen(false)}
>
<div
className="relative w-full max-w-sm animate-fade-in rounded-2xl bg-white p-8 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => setModalOpen(false)}
className="absolute top-4 right-4 flex h-8 w-8 items-center justify-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<h3 className="text-center text-lg font-bold">
</h3>
<p className="mt-1 text-center text-sm text-slate-500">
7
</p>
<div className="mt-6 rounded-xl border border-slate-100 bg-slate-50 p-6 text-center">
<div className="text-6xl">🌍</div>
<p className="mt-3 text-sm font-medium text-slate-600">
</p>
<p className="mt-4 text-3xl font-extrabold text-sky-600">¥199</p>
<p className="mt-1 text-xs text-slate-400">
·
</p>
</div>
<div className="mt-5 rounded-xl border border-slate-100 bg-slate-50 p-4 text-center">
<div className="text-4xl">💬</div>
<p className="mt-2 text-sm font-medium text-slate-600">
</p>
<p className="mt-1 text-xs text-slate-400">
· ·
</p>
</div>
</div>
</div>
)}
</div>
);
}