- Extract landing.css (scoped under .marketing-page) from texpixel-landing.html - Add Lora + JetBrains Mono fonts to index.html - Update MarketingLayout with .marketing-page wrapper and glow blobs - Replace MarketingNavbar with reference design (auth-aware user menu) - Replace HeroSection with mock window + cycling LaTeX typing effect - Replace FeaturesSection, PricingSection, Footer with reference designs - Add ProductSuiteSection, ShowcaseSection, TestimonialsSection (carousel), DocsSeoSection - Add useScrollReveal hook for intersection-based fade-in animations - Update HomePage to wire all sections in correct order - Remove obsolete HowItWorksSection and ContactSection - Remove dead contact key from marketing.nav translations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
184 lines
6.4 KiB
TypeScript
184 lines
6.4 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react';
|
||
import { useLanguage } from '../../contexts/LanguageContext';
|
||
|
||
const TESTIMONIALS = [
|
||
{
|
||
quote: '写论文的时候截图粘进去,LaTeX 就出来了。以前每个公式都要手敲,现在一个截图解决,省了我大概 40% 的时间。',
|
||
name: '林同学',
|
||
role: '数学系研究生 · 北京大学',
|
||
avatarBg: 'var(--teal)',
|
||
avatarColor: '#1a5c54',
|
||
avatarLetter: '林',
|
||
stars: '★★★★★',
|
||
},
|
||
{
|
||
quote: '手写推导拍一张,马上就是干净的 LaTeX。对我这种每天要整理大量笔记的人来说,真的是刚需级别的工具。',
|
||
name: '田晓雯',
|
||
role: '物理学博士候选人 · 清华大学',
|
||
avatarBg: 'var(--lavender)',
|
||
avatarColor: '#3d3870',
|
||
avatarLetter: '田',
|
||
stars: '★★★★★',
|
||
},
|
||
{
|
||
quote: "I use it every day for my thesis. The accuracy on complex integrals and matrix expressions is surprisingly good — way better than anything I've tried before.",
|
||
name: 'Sarah M.',
|
||
role: 'Applied Math PhD · MIT',
|
||
avatarBg: 'var(--rose)',
|
||
avatarColor: '#7a2e1e',
|
||
avatarLetter: 'S',
|
||
stars: '★★★★★',
|
||
},
|
||
{
|
||
quote: 'Desktop 版的离线功能对我来说很重要,论文数据不想上传到云端。买断价格也合理,不用每个月担心订阅。',
|
||
name: '陈博士',
|
||
role: '生物信息学研究员 · 中科院',
|
||
avatarBg: 'var(--gold)',
|
||
avatarColor: '#6f5800',
|
||
avatarLetter: '陈',
|
||
stars: '★★★★★',
|
||
},
|
||
{
|
||
quote: 'The browser extension is a game changer. I copy equations from Claude or ChatGPT straight into Word as native math — no more reformatting nightmares.',
|
||
name: 'Alex K.',
|
||
role: 'Engineering Student · TU Berlin',
|
||
avatarBg: '#8CB4C9',
|
||
avatarColor: '#1a3d52',
|
||
avatarLetter: 'A',
|
||
stars: '★★★★★',
|
||
},
|
||
{
|
||
quote: '教材扫描件里的公式以前完全没法用,现在截图一框就搞定。连化学方程式都能识别,超出我的预期。',
|
||
name: '王梓涵',
|
||
role: '化学工程本科 · 浙江大学',
|
||
avatarBg: '#C9A88C',
|
||
avatarColor: '#4a2e14',
|
||
avatarLetter: '王',
|
||
stars: '★★★★☆',
|
||
},
|
||
];
|
||
|
||
const VISIBLE = 3;
|
||
const TOTAL = TESTIMONIALS.length;
|
||
const PAGES = TOTAL - VISIBLE + 1; // 4
|
||
|
||
export default function TestimonialsSection() {
|
||
const { language } = useLanguage();
|
||
const [currentPage, setCurrentPage] = useState(0);
|
||
const trackRef = useRef<HTMLDivElement>(null);
|
||
const autoTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
|
||
const goTo = useCallback((idx: number) => {
|
||
const clamped = Math.max(0, Math.min(idx, PAGES - 1));
|
||
setCurrentPage(clamped);
|
||
if (trackRef.current) {
|
||
const cardW = trackRef.current.parentElement!.offsetWidth;
|
||
const gap = 20;
|
||
const singleW = (cardW - gap * (VISIBLE - 1)) / VISIBLE;
|
||
const offset = clamped * (singleW + gap);
|
||
trackRef.current.style.transform = `translateX(-${offset}px)`;
|
||
}
|
||
}, []);
|
||
|
||
const resetAuto = useCallback(() => {
|
||
if (autoTimerRef.current) clearInterval(autoTimerRef.current);
|
||
autoTimerRef.current = setInterval(() => {
|
||
setCurrentPage((prev) => {
|
||
const next = (prev + 1) % PAGES;
|
||
goTo(next);
|
||
return next;
|
||
});
|
||
}, 5000);
|
||
}, [goTo]);
|
||
|
||
useEffect(() => {
|
||
resetAuto();
|
||
return () => { if (autoTimerRef.current) clearInterval(autoTimerRef.current); };
|
||
}, [resetAuto]);
|
||
|
||
useEffect(() => {
|
||
const handleResize = () => goTo(currentPage);
|
||
window.addEventListener('resize', handleResize);
|
||
return () => window.removeEventListener('resize', handleResize);
|
||
}, [currentPage, goTo]);
|
||
|
||
const handleGoTo = (idx: number) => {
|
||
goTo(idx);
|
||
resetAuto();
|
||
};
|
||
|
||
return (
|
||
<section className="user-love">
|
||
<div className="container">
|
||
<div className="section-header reveal">
|
||
<div className="eyebrow">User Love</div>
|
||
<h2 className="section-title">
|
||
{language === 'zh' ? '全球学生都在用' : 'Loved by students worldwide'}
|
||
</h2>
|
||
<p className="section-desc">
|
||
{language === 'zh' ? '来自真实用户的反馈。' : 'Feedback from real users.'}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="testimonial-wrap reveal">
|
||
<div
|
||
className="testimonial-track"
|
||
ref={trackRef}
|
||
style={{ transition: 'transform 0.4s ease' }}
|
||
>
|
||
{TESTIMONIALS.map((t, i) => (
|
||
<div key={i} className="testimonial-card">
|
||
<div className="t-quote">"</div>
|
||
<p className="t-body">{t.quote}</p>
|
||
<div className="t-footer">
|
||
<div
|
||
className="t-avatar"
|
||
style={{ background: t.avatarBg, color: t.avatarColor }}
|
||
>
|
||
{t.avatarLetter}
|
||
</div>
|
||
<div>
|
||
<div className="t-name">{t.name}</div>
|
||
<div className="t-role">{t.role}</div>
|
||
</div>
|
||
<div className="t-stars">{t.stars}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="testimonial-nav">
|
||
<button
|
||
className="t-nav-btn"
|
||
onClick={() => handleGoTo(currentPage - 1)}
|
||
aria-label={language === 'zh' ? '上一条' : 'Previous'}
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M15 18l-6-6 6-6" />
|
||
</svg>
|
||
</button>
|
||
<div className="t-dots">
|
||
{Array.from({ length: PAGES }).map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className={`t-dot${i === currentPage ? ' active' : ''}`}
|
||
onClick={() => handleGoTo(i)}
|
||
/>
|
||
))}
|
||
</div>
|
||
<button
|
||
className="t-nav-btn"
|
||
onClick={() => handleGoTo(currentPage + 1)}
|
||
aria-label={language === 'zh' ? '下一条' : 'Next'}
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<path d="M9 18l6-6-6-6" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|