This commit is contained in:
eric
2026-03-10 01:35:37 -05:00
parent ff51622bb6
commit be7a298604
69 changed files with 11016 additions and 57 deletions

View File

@@ -0,0 +1,27 @@
import { Section } from "../Section";
import { AUDIENCE_CONFIG } from "@/config/course";
export function AudienceSection() {
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{AUDIENCE_CONFIG.title}
</h2>
<p className="mb-10 text-center text-[var(--muted-foreground)] sm:mb-12 md:mb-16">
{AUDIENCE_CONFIG.subtitle}
</p>
<div className="grid gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4">
{AUDIENCE_CONFIG.items.map((a) => (
<div
key={a.title}
className="hover-shimmer rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-300 hover:border-[var(--accent)]/30 hover:bg-[var(--muted)] hover:-translate-y-0.5 sm:rounded-2xl sm:p-6"
>
<div className="mb-3 text-2xl sm:mb-4 sm:text-3xl">{a.emoji}</div>
<h3 className="mb-2 font-semibold">{a.title}</h3>
<p className="text-sm text-[var(--muted-foreground)]">{a.desc}</p>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,31 @@
import { Section } from "../Section";
import { BENEFITS_CONFIG } from "@/config/course";
export function BenefitsSection() {
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{BENEFITS_CONFIG.title}
</h2>
<p className="mb-10 text-center text-[var(--muted-foreground)] sm:mb-12 md:mb-16">
{BENEFITS_CONFIG.subtitle}
</p>
<div className="space-y-4 sm:space-y-6">
{BENEFITS_CONFIG.items.map((b) => (
<div
key={b.num}
className="hover-shimmer flex flex-col gap-4 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-300 hover:border-[var(--accent)]/30 hover:-translate-y-0.5 sm:flex-row sm:gap-6 sm:rounded-2xl sm:p-6"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--accent-muted)] text-base font-bold text-[var(--accent)] sm:h-12 sm:w-12 sm:text-lg">
{b.num}
</div>
<div className="min-w-0 flex-1">
<h3 className="mb-1 font-semibold">{b.title}</h3>
<p className="text-sm text-[var(--muted-foreground)]">{b.desc}</p>
</div>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { CTA_CONFIG } from "@/config/course";
import { isPaid } from "@/app/lib/payment";
export function CTASection() {
const [paid, setPaid] = useState(false);
useEffect(() => {
setPaid(isPaid());
const onPayUpdate = () => setPaid(isPaid());
window.addEventListener("pay:updated", onPayUpdate);
return () => window.removeEventListener("pay:updated", onPayUpdate);
}, []);
return (
<section className="border-t border-[var(--border)] py-16 sm:py-20 md:py-24">
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6">
<h2 className="mb-4 text-xl font-bold sm:text-2xl md:text-3xl">
{CTA_CONFIG.title}
</h2>
<p className="mb-6 text-[var(--muted-foreground)] sm:mb-8">
{CTA_CONFIG.subtitle}
</p>
<div className="mb-8 flex flex-wrap justify-center gap-4 text-sm text-[var(--muted-foreground)] sm:mb-10 sm:gap-6">
{CTA_CONFIG.badges.map((b, i) => (
<span
key={b}
className="animate-float-badge rounded-full border border-[var(--border)] bg-[var(--card)] px-4 py-2"
style={{ animationDelay: `${i * 0.2}s` }}
>
{b}
</span>
))}
</div>
<Link
href={paid ? "/course/0/0" : "/pay"}
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition-all duration-300 hover:scale-105 hover:opacity-95 hover:shadow-xl hover:shadow-[var(--accent)]/30 sm:px-10"
>
{paid ? "开始学习 →" : "立即加入 →"}
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,159 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Section } from "../Section";
import { CURRICULUM_CONFIG } from "@/config/course";
import { canWatchLesson } from "@/app/lib/payment";
import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/lib/learning";
export function CurriculumSection() {
const [expanded, setExpanded] = useState<Record<number, boolean>>(() =>
Object.fromEntries(CURRICULUM_CONFIG.modules.map((_, i) => [i, i === 0]))
);
const [search, setSearch] = useState("");
const [bookmarks, setBookmarks] = useState<Set<string>>(new Set());
const [completed, setCompleted] = useState<Set<string>>(new Set());
useEffect(() => {
setBookmarks(getBookmarks());
setCompleted(getCompletedLessons());
const onB = () => setBookmarks(getBookmarks());
const onP = () => setCompleted(getCompletedLessons());
window.addEventListener("learning:bookmarks", onB);
window.addEventListener("learning:progress", onP);
return () => {
window.removeEventListener("learning:bookmarks", onB);
window.removeEventListener("learning:progress", onP);
};
}, []);
const toggle = (i: number) => {
setExpanded((prev) => ({ ...prev, [i]: !prev[i] }));
};
const allLessons = CURRICULUM_CONFIG.modules.flatMap((m, mi) =>
m.lessons.map((l, li) => ({ moduleIndex: mi, lessonIndex: li, ...l, moduleTitle: m.title }))
);
const filtered = search.trim()
? allLessons.filter(
(l) =>
l.name.toLowerCase().includes(search.toLowerCase()) ||
l.moduleTitle.toLowerCase().includes(search.toLowerCase())
)
: allLessons;
const filteredByModule = CURRICULUM_CONFIG.modules
.map((m, mi) => ({
...m,
lessons: filtered.filter((l) => l.moduleIndex === mi),
}))
.filter((m) => m.lessons.length > 0);
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{CURRICULUM_CONFIG.title}
</h2>
<p className="mb-6 text-center text-[var(--muted-foreground)] sm:mb-8 md:mb-10">
{CURRICULUM_CONFIG.subtitle}
</p>
<div className="mx-auto mb-8 max-w-md">
<input
type="search"
placeholder="搜索课程..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-full border border-[var(--border)] bg-[var(--background)] px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
/>
</div>
<div className="space-y-2 md:space-y-3">
{filteredByModule.map((c) => {
const moduleIndex = CURRICULUM_CONFIG.modules.findIndex((m) => m.title === c.title);
const isOpen = expanded[moduleIndex] ?? true;
return (
<div
key={c.title}
className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] md:rounded-2xl"
>
<button
type="button"
onClick={() => toggle(moduleIndex)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)] sm:px-5 sm:py-4"
>
<span className="flex items-center gap-2 text-base font-semibold sm:text-lg">
<span
className={`transition-transform duration-200 ${isOpen ? "rotate-90" : ""}`}
>
▶
</span>
<span>{c.emoji}</span>
{c.title}
</span>
<span className="text-sm text-[var(--muted-foreground)]">
{c.lessons.length} 节
</span>
</button>
{isOpen && (
<ul className="border-t border-[var(--border)]">
{c.lessons.map((l) => {
const free = l.moduleIndex === 0 && l.lessonIndex < 2;
const unlocked = canWatchLesson(l.moduleIndex, l.lessonIndex);
const key = `${l.moduleIndex}-${l.lessonIndex}`;
const bookmarked = isBookmarked(l.moduleIndex, l.lessonIndex);
const isCompleted = completed.has(key);
return (
<li key={key}>
<Link
href={`/course/${l.moduleIndex}/${l.lessonIndex}`}
className="flex flex-col gap-1 px-4 py-3 transition hover:bg-[var(--muted)] sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-3"
>
<span className="flex items-center gap-2 truncate sm:gap-3">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleBookmark(l.moduleIndex, l.lessonIndex);
}}
className="shrink-0 text-xs"
aria-label={bookmarked ? "取消收藏" : "收藏"}
>
{bookmarked ? "⭐" : "☆"}
</button>
<span className={unlocked ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)]"}>
{l.name}
</span>
{free && (
<span className="shrink-0 rounded bg-[var(--accent)]/20 px-1.5 py-0.5 text-xs text-[var(--accent)]">
试看
</span>
)}
{isCompleted && (
<span className="shrink-0 rounded bg-green-500/20 px-1.5 py-0.5 text-xs text-green-600 dark:text-green-400">
✓
</span>
)}
{!unlocked && (
<span className="shrink-0 text-xs text-[var(--muted-foreground)]">
🔒
</span>
)}
</span>
<span className="flex shrink-0 items-center gap-2 text-xs text-[var(--muted-foreground)] sm:text-sm">
{l.duration}
<span className="text-[var(--accent)]">→</span>
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
})}
</div>
</Section>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { Section } from "../Section";
import { FAQ_CONFIG } from "@/config/course";
export function FAQSection() {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<Section>
<h2 className="mb-10 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{FAQ_CONFIG.title}
</h2>
<div className="mx-auto max-w-2xl space-y-2">
{FAQ_CONFIG.items.map((item, i) => (
<div
key={i}
className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] transition"
>
<button
type="button"
onClick={() => setOpenIndex(openIndex === i ? null : i)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)] sm:px-5"
>
<span className="font-medium">{item.q}</span>
<span
className={`shrink-0 text-[var(--muted-foreground)] transition-transform ${openIndex === i ? "rotate-180" : ""}`}
>
▼
</span>
</button>
{openIndex === i && (
<div className="border-t border-[var(--border)] px-4 py-3 text-sm text-[var(--muted-foreground)] sm:px-5">
{item.a}
</div>
)}
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,37 @@
import { Section } from "../Section";
import { INSTRUCTORS_CONFIG } from "@/config/course";
export function InstructorsSection() {
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{INSTRUCTORS_CONFIG.title}
</h2>
<p className="mb-10 text-center text-[var(--muted-foreground)] sm:mb-12 md:mb-16">
{INSTRUCTORS_CONFIG.subtitle}
</p>
<div className="grid gap-6 sm:grid-cols-2 md:gap-8 lg:grid-cols-3">
{INSTRUCTORS_CONFIG.items.map((i) => (
<div
key={i.name}
className="hover-shimmer rounded-xl border border-[var(--border)] bg-[var(--card)] p-5 text-center transition-all duration-300 hover:-translate-y-0.5 hover:border-[var(--accent)]/30 sm:rounded-2xl sm:p-6"
>
<div className="mb-3 flex justify-center sm:mb-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-[var(--accent-muted)] text-xl font-bold text-[var(--accent)] sm:h-20 sm:w-20 sm:text-2xl">
{i.name.slice(0, 1)}
</div>
</div>
<h3 className="mb-1 font-semibold">{i.name}</h3>
<p className="mb-1 text-sm text-[var(--accent)]">{i.role}</p>
<p className="mb-2 text-sm text-[var(--muted-foreground)]">
{i.desc}
</p>
<span className="inline-block rounded-full bg-[var(--accent-muted)] px-3 py-1 text-xs text-[var(--accent)]">
{i.tag}
</span>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { useState, useEffect } from "react";
import { getRating, setRating } from "@/app/lib/learning";
export function RatingSection() {
const [stars, setStars] = useState(0);
const [comment, setComment] = useState("");
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
const r = getRating();
if (r) {
setStars(r.stars);
setComment(r.comment);
setSubmitted(true);
}
}, []);
const handleSubmit = () => {
setRating(stars, comment);
setSubmitted(true);
};
return (
<section className="border-t border-[var(--border)] py-12 sm:py-16">
<div className="mx-auto max-w-2xl px-4 text-center sm:px-6">
<h2 className="mb-2 text-xl font-bold sm:text-2xl">课程评价</h2>
<p className="mb-6 text-sm text-[var(--muted-foreground)]">
你的反馈对我们很重要
</p>
{submitted ? (
<p className="text-[var(--muted-foreground)]">感谢你的评价 ✓</p>
) : (
<>
<div className="mb-4 flex justify-center gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
onClick={() => setStars(n)}
className="text-2xl transition hover:scale-110"
>
{n <= stars ? "⭐" : "☆"}
</button>
))}
</div>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="选填:一句话评价"
className="mb-4 w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
rows={2}
/>
<button
type="button"
onClick={handleSubmit}
className="rounded-full bg-[var(--accent)] px-6 py-2 text-sm font-medium text-[var(--accent-foreground)] hover:opacity-90"
>
提交评价
</button>
</>
)}
</div>
</section>
);
}