瀏覽代碼

feat: 新增产品落地页MarketingPage,完善个人中心Profile与帮助中心交互

bob 1 周之前
父節點
當前提交
d035306afd
共有 10 個文件被更改,包括 329 次插入10 次删除
  1. 20 0
      server/index.ts
  2. 5 2
      src/App.tsx
  3. 3 1
      src/api.ts
  4. 5 1
      src/components/Layout.tsx
  5. 3 2
      src/pages/AuthPage.tsx
  6. 21 1
      src/pages/HelpPage.tsx
  7. 114 0
      src/pages/MarketingPage.tsx
  8. 0 1
      src/pages/Profile.tsx
  9. 150 2
      src/styles.css
  10. 8 0
      src/types.ts

+ 20 - 0
server/index.ts

@@ -222,6 +222,26 @@ app.post('/api/auth/logout', { preHandler: auth }, async (req) => {
 
 app.get('/api/me', { preHandler: auth }, async (req) => ({ user: mapUser((req as any).user) }))
 
+app.get('/api/me/points-transactions', { preHandler: auth }, async (req) => {
+  const { rows } = await pool.query(
+    `SELECT id, name, COALESCE(actual_points, estimated_points) AS points, completed_at
+     FROM courses
+     WHERE user_id = $1 AND points_charged = TRUE
+     ORDER BY completed_at DESC NULLS LAST, updated_at DESC`,
+    [userId(req)]
+  )
+  return {
+    balance: Number((req as any).user.points_balance) || 0,
+    transactions: rows.map((row) => ({
+      id: `course-${row.id}`,
+      courseId: row.id,
+      courseName: row.name,
+      points: -Math.abs(Number(row.points) || 0),
+      createdAt: row.completed_at
+    }))
+  }
+})
+
 app.patch('/api/me', { preHandler: auth }, async (req, reply) => {
   const p = z
     .object({

+ 5 - 2
src/App.tsx

@@ -5,7 +5,8 @@ import { NewCourse } from './pages/NewCourse'
 import { CourseWorkspace } from './pages/CourseWorkspace'
 import { AuthPage } from './pages/AuthPage'
 import { Profile } from './pages/Profile'
-import { HelpPage } from './pages/HelpPage'
+import { HelpPage, PublicHelpPage } from './pages/HelpPage'
+import { MarketingPage } from './pages/MarketingPage'
 import { AdminLayout } from './pages/admin/AdminLayout'
 import { AdminDashboard } from './pages/admin/AdminDashboard'
 import { AdminCourses } from './pages/admin/AdminCourses'
@@ -33,6 +34,8 @@ export default function App() {
 
       {/* ================= 普通用户体系 ================= */}
       <Route path="login" element={<AuthPage />} />
+      <Route path="mkt" element={<MarketingPage />} />
+      {!user && <Route path="help" element={<PublicHelpPage />} />}
       {user && user.role !== 'ADMIN' ? (
         <Route element={<Layout />}>
           <Route index element={<Dashboard />} />
@@ -48,7 +51,7 @@ export default function App() {
         path="*"
         element={
           <Navigate
-            to={!user ? '/login' : user.role === 'ADMIN' ? '/admin' : '/'}
+            to={!user ? '/mkt' : user.role === 'ADMIN' ? '/admin' : '/'}
             replace
           />
         }

+ 3 - 1
src/api.ts

@@ -1,4 +1,4 @@
-import type { AdminStats, Course, CourseStatus, CourseTemplate, User } from './types'
+import type { AdminStats, Course, CourseStatus, CourseTemplate, PointsTransaction, User } from './types'
 
 const token = () => localStorage.getItem('xinghen_token')
 
@@ -33,6 +33,8 @@ export const api = {
     }),
   logout: () => request<{ ok: boolean }>('/auth/logout', { method: 'POST' }),
   me: () => request<{ user: User }>('/me'),
+  getPointsTransactions: () =>
+    request<{ balance: number; transactions: PointsTransaction[] }>('/me/points-transactions'),
   updateMe: (body: Pick<User, 'organization' | 'wechat' | 'contactName' | 'bio'>) =>
     request<{ user: User }>('/me', {
       method: 'PATCH',

+ 5 - 1
src/components/Layout.tsx

@@ -19,7 +19,11 @@ export function Layout() {
           </NavLink>
         </nav>
         <div className="top-actions">
-          {user?.role !== 'ADMIN' && <span className="points-balance-badge">积分 {user?.pointsBalance ?? 0}</span>}
+          {user?.role !== 'ADMIN' && (
+            <NavLink to="/profile?tab=points" className="points-balance-badge" title="查看积分消费记录">
+              积分 {user?.pointsBalance ?? 0}
+            </NavLink>
+          )}
           <NavLink to="/profile" className="user-link">
             <UserRound size={16} />
             <span>{user?.contactName || user?.phone}</span>

+ 3 - 2
src/pages/AuthPage.tsx

@@ -1,13 +1,14 @@
 import { FormEvent, useState } from 'react'
-import { Navigate } from 'react-router-dom'
+import { Navigate, useSearchParams } from 'react-router-dom'
 import { ArrowRight, LockKeyhole, Phone } from 'lucide-react'
 import { Brand } from '../components/Brand'
 import { Footer } from '../components/Footer'
 import { useAuth } from '../AuthContext'
 
 export function AuthPage() {
+  const [searchParams] = useSearchParams()
   const { user, authenticate } = useAuth()
-  const [mode, setMode] = useState<'login' | 'register'>('login')
+  const [mode, setMode] = useState<'login' | 'register'>(() => searchParams.get('mode') === 'register' ? 'register' : 'login')
   const [busy, setBusy] = useState(false)
   const [error, setError] = useState('')
 

+ 21 - 1
src/pages/HelpPage.tsx

@@ -14,6 +14,9 @@ import {
 } from 'lucide-react'
 import { Link } from 'react-router-dom'
 import { ContactModal } from '../components/ContactModal'
+import { Brand } from '../components/Brand'
+import { Footer } from '../components/Footer'
+import { useAuth } from '../AuthContext'
 
 interface FAQItem {
   id: string
@@ -171,6 +174,7 @@ function matchesSearch(values: string[], keyword: string) {
 }
 
 export function HelpPage() {
+  const { user } = useAuth()
   const [activeCategory, setActiveCategory] = useState<string>('all')
   const [searchKeyword, setSearchKeyword] = useState<string>('')
   const [openId, setOpenId] = useState<string | null>('what-is-xinghen')
@@ -202,7 +206,7 @@ export function HelpPage() {
   return (
     <div className="page help-page">
       <Link to="/" className="back-link">
-        <ArrowLeft size={16} /> 返回我的课程
+        <ArrowLeft size={16} /> {user ? '返回我的课程' : '返回首页'}
       </Link>
 
       {/* 顶部介绍区 */}
@@ -364,3 +368,19 @@ export function HelpPage() {
     </div>
   )
 }
+
+export function PublicHelpPage() {
+  return (
+    <div className="marketing-page auth-wrapper">
+      <header className="marketing-nav public-help-nav">
+        <Link to="/mkt" aria-label="星痕首页"><Brand /></Link>
+        <div className="marketing-nav-actions">
+          <Link className="marketing-login" to="/login">登录</Link>
+          <Link className="primary-button small-button" to="/login?mode=register">免费注册</Link>
+        </div>
+      </header>
+      <main className="app-main"><HelpPage /></main>
+      <Footer />
+    </div>
+  )
+}

+ 114 - 0
src/pages/MarketingPage.tsx

@@ -0,0 +1,114 @@
+import { useEffect } from 'react'
+import { ArrowRight, Check, FileText, Layers3, MessageSquareText, Sparkles } from 'lucide-react'
+import { Link, useSearchParams } from 'react-router-dom'
+import { Brand } from '../components/Brand'
+import { Footer } from '../components/Footer'
+
+const steps = [
+  { number: '01', icon: FileText, title: '创建课程', text: '填写课程主题、目标受众与核心内容,快速建立制作任务。' },
+  { number: '02', icon: Layers3, title: '完善素材', text: '集中整理讲师信息、课程大纲与参考资料,告别反复传文件。' },
+  { number: '03', icon: MessageSquareText, title: '协同制作', text: '提交制作需求,随时查看进度,并在同一处沟通调整意见。' },
+  { number: '04', icon: Sparkles, title: '验收交付', text: '在线确认成片与课程资产,让每一次交付都有迹可循。' },
+]
+
+export function MarketingPage() {
+  const [searchParams] = useSearchParams()
+  const source = (searchParams.get('from') || '').trim().slice(0, 100)
+  const authQuery = source
+    ? `/login?mode=register&from=${encodeURIComponent(source)}`
+    : '/login?mode=register'
+  const loginQuery = source ? `/login?from=${encodeURIComponent(source)}` : '/login'
+
+  useEffect(() => {
+    if (!source) return
+
+    const storageKey = 'xinghen_marketing_visit'
+    const now = new Date().toISOString()
+    let previous: { firstVisitedAt?: string } = {}
+    try {
+      previous = JSON.parse(localStorage.getItem(storageKey) || '{}')
+    } catch {
+      // Ignore malformed historical data and start a fresh attribution record.
+    }
+
+    localStorage.setItem(storageKey, JSON.stringify({
+      source,
+      firstVisitedAt: previous.firstVisitedAt || now,
+      lastVisitedAt: now,
+    }))
+  }, [source])
+
+  return (
+    <div className="marketing-page">
+      <header className="marketing-nav">
+        <Link to="/" aria-label="星痕首页"><Brand /></Link>
+        <nav aria-label="首页导航">
+          <a href="#about">关于星痕</a>
+          <a href="#process">制作流程</a>
+        </nav>
+        <div className="marketing-nav-actions">
+          <Link className="marketing-login" to={loginQuery}>登录</Link>
+          <Link className="primary-button small-button" to={authQuery}>免费注册</Link>
+        </div>
+      </header>
+
+      <main>
+        <section className="marketing-hero" id="about">
+          <div className="marketing-hero-copy">
+            <p className="marketing-kicker"><span /> 智能课程工坊</p>
+            <h1>把好内容,<br />制作成一门<em>好课程。</em></h1>
+            <p className="marketing-lead">星痕连接课程策划、素材管理与制作交付,让讲师和制作团队在一处高效协作,从一个想法走向一门完整课程。</p>
+            <div className="marketing-hero-actions">
+              <Link className="primary-button marketing-cta" to={authQuery}>开始制作课程 <ArrowRight size={17} /></Link>
+              <a className="marketing-text-link" href="#process">了解制作流程 <span>↓</span></a>
+            </div>
+            <div className="marketing-trust">
+              <span><Check size={14} /> 免费创建账号</span>
+              <span><Check size={14} /> 流程清晰可追踪</span>
+              <span><Check size={14} /> 课程资产统一管理</span>
+            </div>
+          </div>
+
+          <div className="marketing-visual" aria-hidden="true">
+            <div className="visual-orbit visual-orbit-a" />
+            <div className="visual-orbit visual-orbit-b" />
+            <div className="visual-star star-a" />
+            <div className="visual-star star-b" />
+            <div className="course-preview-card">
+              <div className="preview-top"><span>COURSE / 01</span><i /></div>
+              <div className="preview-lines"><b /><b /><b /></div>
+              <div className="preview-progress"><span>课程资料准备</span><strong>75%</strong></div>
+              <div className="preview-bar"><i /></div>
+              <div className="preview-stages"><span className="done">课程创建</span><span className="done">素材整理</span><span>制作交付</span></div>
+            </div>
+            <p className="visual-note">让每一门好课,<br />留下清晰的星痕。</p>
+          </div>
+        </section>
+
+        <section className="marketing-process" id="process">
+          <div className="marketing-section-heading">
+            <div><p>HOW IT WORKS</p><h2>从想法到成课,只需四步</h2></div>
+            <p>把复杂的课程制作拆成清晰、可追踪的协作流程。</p>
+          </div>
+          <div className="process-grid">
+            {steps.map(({ number, icon: Icon, title, text }) => (
+              <article className="process-card" key={number}>
+                <div className="process-card-top"><span>{number}</span><Icon size={20} strokeWidth={1.6} /></div>
+                <h3>{title}</h3><p>{text}</p>
+              </article>
+            ))}
+          </div>
+        </section>
+
+        <section className="marketing-final-cta">
+          <div className="final-orbit" aria-hidden="true"><i /></div>
+          <p>START YOUR COURSE</p>
+          <h2>下一门好课,从这里开始。</h2>
+          <span>注册星痕,建立你的第一个课程制作任务。</span>
+          <Link className="primary-button marketing-cta" to={authQuery}>免费创建账号 <ArrowRight size={17} /></Link>
+        </section>
+      </main>
+      <Footer />
+    </div>
+  )
+}

文件差異過大導致無法顯示
+ 0 - 1
src/pages/Profile.tsx


+ 150 - 2
src/styles.css

@@ -260,8 +260,61 @@ button { cursor:pointer; }
 .auth-card input,.instructor-form input,.instructor-form textarea { width:100%; border:1px solid #dfe4ec; border-radius:7px; padding:12px 13px; background:#fbfcfe; }
 .input-with-icon input { padding-left:41px; }
 .auth-switch { display:block; margin:18px auto 0; border:0; background:transparent; color:var(--blue); }
-.profile-grid { display:grid; grid-template-columns:minmax(500px,700px) 350px; gap:22px; justify-content:center; }
-.password-card { align-self:start; }
+.profile-page { max-width:1180px; padding-top:34px; }
+.profile-settings { display:grid; grid-template-columns:270px minmax(0,1fr); gap:24px; align-items:start; margin-top:20px; }
+.profile-sidebar { position:sticky; top:96px; overflow:hidden; border:1px solid #e1e6ef; border-radius:14px; background:rgba(255,255,255,.78); box-shadow:0 14px 40px rgba(35,50,80,.05); backdrop-filter:blur(14px); }
+.profile-sidebar-heading { display:flex; align-items:center; gap:12px; padding:20px; border-bottom:1px solid #e8ecf2; }
+.profile-sidebar-heading>div:last-child { min-width:0; display:flex; flex-direction:column; gap:4px; }
+.profile-sidebar-heading strong { overflow:hidden; font-size:14px; text-overflow:ellipsis; white-space:nowrap; }
+.profile-sidebar-heading span { color:#929baa; font-size:11px; }
+.profile-avatar { width:40px; height:40px; flex:none; display:grid; place-items:center; border-radius:10px; color:#245bd6; background:#edf3ff; }
+.profile-sidebar nav { display:flex; flex-direction:column; gap:4px; padding:8px; }
+.profile-sidebar nav button { width:100%; display:grid; grid-template-columns:20px minmax(0,1fr) 15px; gap:11px; align-items:center; padding:12px; border:0; border-radius:9px; color:#69758a; background:transparent; text-align:left; }
+.profile-sidebar nav button>span { display:flex; flex-direction:column; gap:3px; }
+.profile-sidebar nav button strong { color:#354157; font-size:13px; font-weight:600; }
+.profile-sidebar nav button small { color:#9aa3b2; font-size:10px; }
+.profile-sidebar nav button>svg:last-child { opacity:.55; }
+.profile-sidebar nav button:hover { background:#f3f6fb; }
+.profile-sidebar nav button.active { color:#245bd6; background:#eaf1ff; box-shadow:inset 0 0 0 1px rgba(36,91,214,.08); }
+.profile-sidebar nav button.active strong { color:#184ebc; }
+.profile-content { min-width:0; }
+.profile-panel { min-height:560px; }
+.profile-panel .form-title p { margin:7px 0 0; color:#929bad; font-size:12px; }
+.profile-security { max-width:650px; }
+.points-wallet { display:flex; align-items:center; gap:16px; margin:25px 0 31px; padding:22px 24px; border:1px solid #f0dfcc; border-radius:12px; background:linear-gradient(135deg,#fff8ef,#fffdf9); }
+.points-wallet-icon { width:48px; height:48px; display:grid; place-items:center; flex:none; border-radius:13px; color:#c76520; background:#ffead4; }
+.points-wallet>div:last-child { display:flex; align-items:baseline; gap:7px; flex-wrap:wrap; }
+.points-wallet span { width:100%; color:#8d755f; font-size:11px; }
+.points-wallet strong { color:#b95519; font-size:30px; line-height:1; }
+.points-wallet small { color:#a97c5c; font-size:12px; }
+.points-history-heading { display:flex; align-items:center; justify-content:space-between; padding-bottom:12px; border-bottom:1px solid #e9edf3; }
+.points-history-heading h3 { margin:0; font-size:14px; }
+.points-history-heading span { color:#929baa; font-size:11px; }
+.points-transaction-list { display:flex; flex-direction:column; }
+.points-transaction { display:grid; grid-template-columns:38px minmax(0,1fr) auto; gap:13px; align-items:center; padding:16px 4px; border-bottom:1px solid #edf0f4; transition:.2s; }
+.points-transaction:hover { padding-left:9px; padding-right:9px; background:#f9fafc; }
+.transaction-icon { width:36px; height:36px; display:grid; place-items:center; border-radius:10px; color:#ba5e24; background:#fff0e4; }
+.transaction-main,.transaction-value { display:flex; flex-direction:column; gap:5px; }
+.transaction-main { min-width:0; }
+.transaction-main strong { overflow:hidden; color:#303b4e; font-size:13px; text-overflow:ellipsis; white-space:nowrap; }
+.transaction-main span,.transaction-value time { color:#9aa3b1; font-size:10px; }
+.transaction-value { align-items:flex-end; text-align:right; }
+.transaction-value strong { color:#d2542e; font-size:14px; }
+.points-empty { min-height:240px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:9px; color:#a0a9b7; font-size:12px; text-align:center; }
+.points-empty strong { color:#69758a; font-size:13px; }
+.points-empty span { color:#a0a9b7; font-size:11px; }
+@media (max-width:760px) {
+  .profile-page { padding-top:22px; }
+  .profile-settings { grid-template-columns:1fr; }
+  .profile-sidebar { position:static; }
+  .profile-sidebar-heading { display:none; }
+  .profile-sidebar nav { flex-direction:row; overflow-x:auto; }
+  .profile-sidebar nav button { min-width:max-content; grid-template-columns:18px auto; padding:10px 12px; }
+  .profile-sidebar nav button small,.profile-sidebar nav button>svg:last-child { display:none; }
+  .profile-panel { min-height:0; }
+  .points-transaction { grid-template-columns:36px minmax(0,1fr); }
+  .transaction-value { grid-column:2; align-items:flex-start; flex-direction:row; justify-content:space-between; }
+}
 .notice { padding:12px 16px; border-radius:8px; margin:14px 0; font-size:13px; }
 .notice-error { color:#ad504a; background:#fff1f0; border:1px solid #f3d9d7; }
 .notice-success { color:#2e7d32; background:#e8f5e9; border:1px solid #c8e6c9; }
@@ -3267,6 +3320,101 @@ button { cursor:pointer; }
   }
 }
 
+/* ==========================================================================
+   公开营销首页
+   ========================================================================== */
+.marketing-page { min-height:100vh; background:#f7f8fb; color:var(--ink); overflow:hidden; }
+.marketing-nav { height:76px; max-width:1400px; margin:0 auto; padding:0 clamp(24px,5vw,76px); display:flex; align-items:center; border-bottom:1px solid rgba(220,225,235,.75); }
+.marketing-nav nav { display:flex; gap:34px; margin-left:70px; font-size:13px; color:#6f7b90; }
+.marketing-nav nav a:hover,.marketing-login:hover { color:var(--blue); }
+.marketing-nav-actions { margin-left:auto; display:flex; align-items:center; gap:22px; }
+.marketing-login { font-size:13px; color:#536077; }
+.public-help-nav { width:100%; flex:0 0 76px; }
+.marketing-hero { width:min(1400px,100%); min-height:660px; margin:0 auto; padding:90px clamp(24px,5vw,76px) 80px; display:grid; grid-template-columns:1.05fr .95fr; align-items:center; position:relative; }
+.marketing-hero-copy { position:relative; z-index:2; max-width:650px; }
+.marketing-kicker { display:flex; align-items:center; gap:10px; color:#5572ad; font-size:11px; font-weight:600; letter-spacing:.18em; margin:0 0 27px; }
+.marketing-kicker span { width:22px; height:1px; background:#c09b57; }
+.marketing-hero h1 { margin:0; font-family:"Songti SC","Noto Serif SC","Source Han Serif CN",serif; font-size:clamp(48px,5vw,72px); font-weight:500; line-height:1.23; letter-spacing:-.045em; }
+.marketing-hero h1 em { color:var(--blue); font-style:normal; }
+.marketing-lead { max-width:570px; margin:28px 0 32px; color:#748096; font-size:16px; line-height:1.9; }
+.marketing-hero-actions { display:flex; align-items:center; gap:28px; }
+.marketing-cta { min-height:49px; padding:0 23px; }
+.marketing-text-link { font-size:13px; color:#647087; display:inline-flex; gap:8px; align-items:center; }
+.marketing-text-link:hover { color:var(--blue); }
+.marketing-trust { display:flex; gap:25px; margin-top:36px; color:#8994a7; font-size:11px; }
+.marketing-trust span { display:flex; align-items:center; gap:6px; }
+.marketing-trust svg { color:#b38b43; }
+.marketing-visual { min-height:470px; position:relative; }
+.visual-orbit { position:absolute; left:0; top:50%; border:1px solid rgba(87,105,138,.18); border-radius:50%; transform:translateY(-50%) rotate(-15deg); }
+.visual-orbit-a { width:640px; height:320px; }
+.visual-orbit-b { width:500px; height:245px; left:70px; border-color:rgba(36,91,214,.14); }
+.visual-star { position:absolute; border-radius:50%; z-index:2; }
+.star-a { width:9px; height:9px; background:#c09a54; left:42px; top:161px; box-shadow:0 0 0 5px rgba(192,154,84,.1); }
+.star-b { width:7px; height:7px; background:#3669d2; right:12px; bottom:123px; }
+.course-preview-card { width:min(410px,82%); position:absolute; left:100px; top:82px; z-index:3; border:1px solid #e0e6f0; border-radius:16px; background:rgba(255,255,255,.88); backdrop-filter:blur(12px); padding:28px; box-shadow:0 30px 80px rgba(31,52,94,.12); transform:rotate(1.5deg); }
+.preview-top { display:flex; justify-content:space-between; align-items:center; color:#94a0b3; font-size:10px; letter-spacing:.18em; }
+.preview-top i { width:8px; height:8px; border-radius:50%; background:#d6ad61; box-shadow:0 0 0 5px #f8f3e8; }
+.preview-lines { margin:34px 0 40px; display:flex; flex-direction:column; gap:12px; }
+.preview-lines b { height:9px; border-radius:5px; background:#edf0f5; width:92%; }
+.preview-lines b:first-child { height:18px; width:68%; background:#27334a; }
+.preview-lines b:last-child { width:55%; }
+.preview-progress { display:flex; justify-content:space-between; color:#707d92; font-size:11px; margin-bottom:10px; }
+.preview-progress strong { color:var(--blue); font-size:12px; }
+.preview-bar { height:5px; background:#edf1f6; border-radius:5px; overflow:hidden; }
+.preview-bar i { display:block; width:75%; height:100%; background:linear-gradient(90deg,#245bd6,#6f94e8); }
+.preview-stages { display:flex; justify-content:space-between; margin-top:25px; color:#a1aabb; font-size:10px; }
+.preview-stages .done { color:#526582; }
+.visual-note { position:absolute; right:-5px; bottom:24px; font-family:"Songti SC",serif; font-size:15px; line-height:1.8; color:#8993a5; letter-spacing:.08em; }
+.marketing-process { width:min(1400px,100%); margin:0 auto; padding:100px clamp(24px,5vw,76px) 110px; border-top:1px solid var(--line); }
+.marketing-section-heading { display:flex; align-items:end; justify-content:space-between; margin-bottom:46px; }
+.marketing-section-heading>div>p,.marketing-final-cta>p { color:#6680b2; font-size:10px; font-weight:600; letter-spacing:.22em; margin:0 0 12px; }
+.marketing-section-heading h2,.marketing-final-cta h2 { font-family:"Songti SC",serif; font-size:clamp(30px,3vw,43px); font-weight:500; margin:0; letter-spacing:-.03em; }
+.marketing-section-heading>p { max-width:360px; color:#8a95a7; font-size:13px; line-height:1.8; margin:0; }
+.process-grid { display:grid; grid-template-columns:repeat(4,1fr); border:1px solid #e1e6ef; border-radius:14px; overflow:hidden; background:rgba(255,255,255,.65); }
+.process-card { min-height:260px; padding:28px; border-right:1px solid #e5e9f1; transition:.25s ease; }
+.process-card:last-child { border-right:0; }
+.process-card:hover { background:#fff; transform:translateY(-3px); box-shadow:0 16px 38px rgba(30,50,88,.07); }
+.process-card-top { display:flex; justify-content:space-between; align-items:center; color:#9aa5b7; }
+.process-card-top span { font-family:Georgia,serif; font-size:12px; color:#b18a45; }
+.process-card-top svg { color:#6e88bb; }
+.process-card h3 { margin:55px 0 14px; font-size:18px; font-weight:600; }
+.process-card p { margin:0; color:#8390a4; font-size:13px; line-height:1.8; }
+.marketing-final-cta { width:min(1248px,calc(100% - 48px)); margin:0 auto 95px; min-height:365px; border-radius:18px; overflow:hidden; position:relative; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; background:linear-gradient(145deg,#edf3ff,#f8f9fc); border:1px solid #dce5f4; }
+.marketing-final-cta>span { color:#7c899e; font-size:14px; margin:18px 0 27px; }
+.final-orbit { position:absolute; width:700px; height:230px; border:1px solid rgba(53,92,171,.14); border-radius:50%; transform:rotate(-9deg); pointer-events:none; }
+.final-orbit i { position:absolute; width:9px; height:9px; border-radius:50%; background:#c29b54; left:65px; top:36px; }
+.marketing-final-cta>*:not(.final-orbit) { position:relative; z-index:1; }
+@media (max-width:900px) {
+  .marketing-hero { grid-template-columns:1fr; padding-top:68px; }
+  .marketing-visual { margin-top:24px; min-height:430px; }
+  .process-grid { grid-template-columns:repeat(2,1fr); }
+  .process-card:nth-child(2) { border-right:0; }
+  .process-card:nth-child(-n+2) { border-bottom:1px solid #e5e9f1; }
+}
+@media (max-width:640px) {
+  .marketing-nav { height:68px; padding:0 18px; }
+  .public-help-nav { flex-basis:68px; }
+  .marketing-nav nav,.marketing-login { display:none; }
+  .marketing-nav-actions { gap:0; }
+  .marketing-hero { padding:58px 20px 50px; min-height:auto; }
+  .marketing-hero h1 { font-size:43px; }
+  .marketing-lead { font-size:14px; }
+  .marketing-hero-actions { align-items:flex-start; flex-direction:column; gap:18px; }
+  .marketing-trust { flex-direction:column; gap:9px; margin-top:28px; }
+  .marketing-visual { min-height:360px; margin-left:-15px; }
+  .visual-orbit-a { width:470px; height:245px; left:-65px; }
+  .visual-orbit-b { width:370px; height:185px; left:-15px; }
+  .course-preview-card { left:28px; top:68px; width:320px; padding:23px; }
+  .visual-note { display:none; }
+  .marketing-process { padding:72px 20px 80px; }
+  .marketing-section-heading { align-items:flex-start; flex-direction:column; gap:18px; }
+  .process-grid { grid-template-columns:1fr; }
+  .process-card { min-height:220px; border-right:0; border-bottom:1px solid #e5e9f1; }
+  .process-card:last-child { border-bottom:0; }
+  .process-card h3 { margin-top:38px; }
+  .marketing-final-cta { width:calc(100% - 32px); margin-bottom:60px; padding:40px 20px; }
+}
+
 .danger-zone-title-row {
   display: flex;
   align-items: center;

+ 8 - 0
src/types.ts

@@ -17,6 +17,14 @@ export interface User {
   pointsBalance: number
 }
 
+export interface PointsTransaction {
+  id: string
+  courseId: string
+  courseName: string
+  points: number
+  createdAt: string
+}
+
 export interface CourseProgressEvent {
   id: string
   actorType: 'USER' | 'ADMIN' | 'SYSTEM'

部分文件因文件數量過多而無法顯示