'init'
This commit is contained in:
27
app/components/sections/AudienceSection.tsx
Normal file
27
app/components/sections/AudienceSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
app/components/sections/BenefitsSection.tsx
Normal file
31
app/components/sections/BenefitsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
app/components/sections/CTASection.tsx
Normal file
46
app/components/sections/CTASection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
app/components/sections/CurriculumSection.tsx
Normal file
159
app/components/sections/CurriculumSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
app/components/sections/FAQSection.tsx
Normal file
43
app/components/sections/FAQSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
app/components/sections/InstructorsSection.tsx
Normal file
37
app/components/sections/InstructorsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
app/components/sections/RatingSection.tsx
Normal file
67
app/components/sections/RatingSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user