511 lines
18 KiB
JavaScript
511 lines
18 KiB
JavaScript
|
||
import React, { useState, useEffect } from 'react';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||
import { ArrowLeftOutlined, ShareAltOutlined, CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined, UserOutlined, UploadOutlined, PayCircleOutlined } from '@ant-design/icons';
|
||
import confetti from 'canvas-confetti';
|
||
import { message, Spin, Button, Result, Modal, Form, Input, Select, Radio, Checkbox, Upload } from 'antd';
|
||
import { getActivityDetail, signUpActivity, queryOrderStatus } from '../../api';
|
||
import styles from '../../components/activity/activity.module.less';
|
||
import { pageTransition, buttonTap } from '../../animation';
|
||
import LoginModal from '../../components/LoginModal';
|
||
import { useAuth } from '../../context/AuthContext';
|
||
import ReactMarkdown from 'react-markdown';
|
||
import remarkGfm from 'remark-gfm';
|
||
import rehypeRaw from 'rehype-raw';
|
||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||
import { QRCodeSVG } from 'qrcode.react';
|
||
|
||
const ActivityDetail = () => {
|
||
const { id } = useParams();
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
const { scrollY } = useScroll();
|
||
const { login, user } = useAuth();
|
||
const [loginVisible, setLoginVisible] = useState(false);
|
||
const [signupFormVisible, setSignupFormVisible] = useState(false);
|
||
const [paymentModalVisible, setPaymentModalVisible] = useState(false);
|
||
const [paymentInfo, setPaymentInfo] = useState(null);
|
||
const [form] = Form.useForm();
|
||
|
||
// Header animation: transparent to white with shadow
|
||
const headerBg = useTransform(scrollY, [0, 60], ['rgba(255,255,255,0)', 'rgba(255,255,255,1)']);
|
||
const headerShadow = useTransform(scrollY, [0, 60], ['none', '0 2px 8px rgba(0,0,0,0.1)']);
|
||
const headerColor = useTransform(scrollY, [0, 60], ['rgba(255,255,255,1)', 'rgba(0,0,0,0.85)']);
|
||
const titleOpacity = useTransform(scrollY, [100, 200], [0, 1]);
|
||
|
||
const { data: activity, isLoading, error, refetch } = useQuery({
|
||
queryKey: ['activity', id],
|
||
queryFn: async () => {
|
||
try {
|
||
const res = await getActivityDetail(id);
|
||
return res.data;
|
||
} catch (err) {
|
||
throw new Error(err.response?.data?.detail || 'Failed to load activity');
|
||
}
|
||
},
|
||
staleTime: 0, // Ensure fresh data
|
||
refetchOnMount: 'always', // Force refetch on mount
|
||
});
|
||
|
||
//// /
|
||
// Force a refresh if needed (as requested by user)
|
||
useEffect(() => {
|
||
// 1. Force React Query refetch
|
||
refetch();
|
||
|
||
// 2. Hard refresh logic after 1 second delay
|
||
const timer = setTimeout(() => {
|
||
const hasRefreshedKey = `has_refreshed_activity_${id}`;
|
||
if (!sessionStorage.getItem(hasRefreshedKey)) {
|
||
sessionStorage.setItem(hasRefreshedKey, 'true');
|
||
window.location.reload();
|
||
}
|
||
}, 0);
|
||
|
||
return () => clearTimeout(timer);
|
||
}, [id, refetch]);
|
||
|
||
// Auto-fill form fields when the signup form becomes visible
|
||
useEffect(() => {
|
||
if (signupFormVisible && user && activity?.signup_form_config) {
|
||
const initialValues = {};
|
||
|
||
activity.signup_form_config.forEach(field => {
|
||
// Auto-fill phone number
|
||
if (field.name === 'phone' && user.phone_number) {
|
||
initialValues[field.name] = user.phone_number;
|
||
}
|
||
// Auto-fill name (nickname) if the field name is 'name'
|
||
if (field.name === 'name' && user.nickname) {
|
||
initialValues[field.name] = user.nickname;
|
||
}
|
||
});
|
||
|
||
if (Object.keys(initialValues).length > 0) {
|
||
form.setFieldsValue(initialValues);
|
||
}
|
||
}
|
||
}, [signupFormVisible, user, activity, form]);
|
||
|
||
const signUpMutation = useMutation({
|
||
mutationFn: (values) => signUpActivity(id, { signup_info: values || {} }),
|
||
onSuccess: (data) => {
|
||
// 检查是否需要支付
|
||
if (data.payment_required) {
|
||
setPaymentInfo(data);
|
||
// 先关闭报名表单,确保层级正确
|
||
setSignupFormVisible(false);
|
||
// 延迟一点点时间打开支付弹窗,避免状态更新冲突
|
||
setTimeout(() => {
|
||
setPaymentModalVisible(true);
|
||
}, 300);
|
||
// 不再显示 message,避免遮挡
|
||
return;
|
||
}
|
||
|
||
message.success('报名成功!');
|
||
setSignupFormVisible(false);
|
||
confetti({
|
||
particleCount: 150,
|
||
spread: 70,
|
||
origin: { y: 0.6 },
|
||
colors: ['#00b96b', '#1890ff', '#ffffff']
|
||
});
|
||
queryClient.invalidateQueries(['activity', id]);
|
||
queryClient.invalidateQueries(['activities']);
|
||
},
|
||
onError: (err) => {
|
||
message.error(err.response?.data?.detail || err.response?.data?.error || '报名失败,请稍后重试');
|
||
}
|
||
});
|
||
|
||
// Polling for payment status
|
||
useEffect(() => {
|
||
let timer;
|
||
if (paymentModalVisible && paymentInfo?.order_id) {
|
||
timer = setInterval(async () => {
|
||
try {
|
||
const response = await queryOrderStatus(paymentInfo.order_id);
|
||
if (response.data.status === 'paid') {
|
||
message.success('支付成功,报名已确认!');
|
||
setPaymentModalVisible(false);
|
||
setPaymentInfo(null);
|
||
|
||
// Trigger success effects
|
||
confetti({
|
||
particleCount: 150,
|
||
spread: 70,
|
||
origin: { y: 0.6 },
|
||
colors: ['#00b96b', '#1890ff', '#ffffff']
|
||
});
|
||
queryClient.invalidateQueries(['activity', id]);
|
||
queryClient.invalidateQueries(['activities']);
|
||
|
||
clearInterval(timer);
|
||
}
|
||
} catch (error) {
|
||
// ignore error during polling
|
||
}
|
||
}, 3000);
|
||
}
|
||
return () => clearInterval(timer);
|
||
}, [paymentModalVisible, paymentInfo, id, queryClient]);
|
||
|
||
const handleShare = async () => {
|
||
const url = window.location.href;
|
||
if (navigator.share) {
|
||
try {
|
||
await navigator.share({
|
||
title: activity?.title,
|
||
text: '来看看这个精彩活动!',
|
||
url: url
|
||
});
|
||
} catch (err) {
|
||
console.log('Share canceled');
|
||
}
|
||
} else {
|
||
navigator.clipboard.writeText(url);
|
||
message.success('链接已复制到剪贴板');
|
||
}
|
||
};
|
||
|
||
const handleSignUp = () => {
|
||
if (!localStorage.getItem('token')) {
|
||
message.warning('请先登录后报名');
|
||
setLoginVisible(true);
|
||
return;
|
||
}
|
||
|
||
// Check if we need to collect info
|
||
if (activity.signup_form_config && activity.signup_form_config.length > 0) {
|
||
setSignupFormVisible(true);
|
||
} else {
|
||
// Direct signup if no info needed
|
||
signUpMutation.mutate({});
|
||
}
|
||
};
|
||
|
||
const handleFormSubmit = (values) => {
|
||
// Handle file uploads if any (convert to base64 or just warn if not supported)
|
||
// For now, we just pass values.
|
||
// Note: File objects won't serialize to JSON well.
|
||
signUpMutation.mutate(values);
|
||
};
|
||
|
||
const normFile = (e) => {
|
||
if (Array.isArray(e)) {
|
||
return e;
|
||
}
|
||
return e?.fileList;
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#1f1f1f' }}>
|
||
<Spin size="large" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div style={{ padding: 40, background: '#1f1f1f', minHeight: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||
<Result
|
||
status="error"
|
||
title="加载失败"
|
||
subTitle={error.message}
|
||
extra={[
|
||
<Button type="primary" key="back" onClick={() => navigate(-1)}>
|
||
返回列表
|
||
</Button>
|
||
]}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const cleanUrl = (url) => {
|
||
if (!url) return '';
|
||
return url.replace(/[`\s]/g, '');
|
||
};
|
||
|
||
return (
|
||
<motion.div
|
||
initial="initial"
|
||
animate="animate"
|
||
exit="exit"
|
||
variants={pageTransition}
|
||
style={{ background: '#1f1f1f', minHeight: '100vh', color: '#fff' }}
|
||
>
|
||
{/* Sticky Header */}
|
||
<motion.div
|
||
style={{
|
||
position: 'fixed',
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
height: 60,
|
||
zIndex: 100,
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
padding: '0 20px',
|
||
background: headerBg,
|
||
boxShadow: headerShadow,
|
||
}}
|
||
>
|
||
<motion.div
|
||
onClick={() => navigate(-1)}
|
||
style={{ cursor: 'pointer', color: headerColor, fontSize: 20 }}
|
||
>
|
||
<ArrowLeftOutlined />
|
||
</motion.div>
|
||
<motion.div
|
||
style={{ color: headerColor, fontWeight: 600, opacity: titleOpacity }}
|
||
>
|
||
{activity.title}
|
||
</motion.div>
|
||
<motion.div
|
||
onClick={handleShare}
|
||
style={{ cursor: 'pointer', color: headerColor, fontSize: 20 }}
|
||
>
|
||
<ShareAltOutlined />
|
||
</motion.div>
|
||
</motion.div>
|
||
|
||
{/* Hero Image with LayoutId for shared transition */}
|
||
<div className={styles.detailHeader}>
|
||
<motion.img
|
||
layoutId={`activity-card-${id}`}
|
||
src={activity.display_banner_url || cleanUrl(activity.banner_url) || activity.cover_image || 'https://via.placeholder.com/800x600'}
|
||
alt={activity.title}
|
||
className={styles.detailImage}
|
||
/>
|
||
<div style={{
|
||
position: 'absolute',
|
||
bottom: 0,
|
||
left: 0,
|
||
width: '100%',
|
||
height: '50%',
|
||
background: 'linear-gradient(to top, #1f1f1f, transparent)'
|
||
}} />
|
||
</div>
|
||
|
||
{/* Content */}
|
||
<div className={styles.detailContent}>
|
||
<div className={styles.infoCard}>
|
||
<h1 style={{ fontSize: 28, marginBottom: 16, color: '#fff' }}>{activity.title}</h1>
|
||
|
||
<div style={{ display: 'flex', gap: 20, marginBottom: 16, color: 'rgba(255,255,255,0.7)' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<CalendarOutlined />
|
||
<span>{activity.start_time ? new Date(activity.start_time).toLocaleDateString() : 'TBD'}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<ClockCircleOutlined />
|
||
<span>{activity.start_time ? new Date(activity.start_time).toLocaleTimeString() : 'TBD'}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<EnvironmentOutlined />
|
||
<span>{activity.location || '线上活动'}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<UserOutlined />
|
||
<span>{activity.current_signups || 0} / {activity.max_participants} 已报名</span>
|
||
</div>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<PayCircleOutlined />
|
||
<span>{activity.is_paid ? `¥${activity.price}` : '免费'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 10 }}>
|
||
<span className={styles.statusTag}>
|
||
{activity.status || (new Date() < new Date(activity.start_time) ? '报名中' : '已结束')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.richText}>
|
||
<h3>活动详情</h3>
|
||
<div style={{ color: '#ccc', lineHeight: '1.8' }}>
|
||
<ReactMarkdown
|
||
remarkPlugins={[remarkGfm]}
|
||
rehypePlugins={[rehypeRaw]}
|
||
components={{
|
||
code({node, inline, className, children, ...props}) {
|
||
const match = /language-(\w+)/.exec(className || '')
|
||
return !inline && match ? (
|
||
<SyntaxHighlighter
|
||
style={vscDarkPlus}
|
||
language={match[1]}
|
||
PreTag="div"
|
||
{...props}
|
||
>
|
||
{String(children).replace(/\n$/, '')}
|
||
</SyntaxHighlighter>
|
||
) : (
|
||
<code className={className} {...props}>
|
||
{children}
|
||
</code>
|
||
)
|
||
}
|
||
}}
|
||
>
|
||
{activity.description || activity.content || '暂无详情描述'}
|
||
</ReactMarkdown>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Fixed Footer */}
|
||
<div className={styles.fixedFooter}>
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.5)' }}>距离报名截止</span>
|
||
<span style={{ color: '#00b96b', fontWeight: 'bold' }}>
|
||
{/* Simple countdown placeholder */}
|
||
3天 12小时
|
||
</span>
|
||
</div>
|
||
<motion.button
|
||
className={styles.actionBtn}
|
||
variants={buttonTap}
|
||
whileTap="tap"
|
||
onClick={handleSignUp}
|
||
disabled={signUpMutation.isPending || activity.is_signed_up}
|
||
>
|
||
{signUpMutation.isPending ? '提交中...' : activity.is_signed_up ? '已报名' : '立即报名'}
|
||
</motion.button>
|
||
</div>
|
||
|
||
<LoginModal
|
||
visible={loginVisible}
|
||
onClose={() => setLoginVisible(false)}
|
||
onLoginSuccess={(userData) => {
|
||
login(userData);
|
||
// Auto trigger signup after login if needed, or just let user click again
|
||
}}
|
||
/>
|
||
|
||
<Modal
|
||
title="填写报名信息"
|
||
open={signupFormVisible}
|
||
onCancel={() => setSignupFormVisible(false)}
|
||
onOk={form.submit}
|
||
okText={activity?.is_paid && activity?.price > 0 ? `支付 ¥${activity.price}` : '提交报名'}
|
||
confirmLoading={signUpMutation.isPending}
|
||
destroyOnHidden
|
||
>
|
||
<Form form={form} onFinish={handleFormSubmit} layout="vertical">
|
||
{activity.signup_form_config && activity.signup_form_config.map(field => {
|
||
let inputNode;
|
||
const commonProps = {
|
||
placeholder: field.placeholder || `请输入${field.label}`,
|
||
};
|
||
|
||
switch (field.type) {
|
||
case 'select':
|
||
inputNode = (
|
||
<Select placeholder={field.placeholder || `请选择${field.label}`}>
|
||
{field.options?.map(opt => (
|
||
<Select.Option key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</Select.Option>
|
||
))}
|
||
</Select>
|
||
);
|
||
break;
|
||
case 'radio':
|
||
inputNode = (
|
||
<Radio.Group>
|
||
{field.options?.map(opt => (
|
||
<Radio key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</Radio>
|
||
))}
|
||
</Radio.Group>
|
||
);
|
||
break;
|
||
case 'checkbox':
|
||
inputNode = (
|
||
<Checkbox.Group>
|
||
{field.options?.map(opt => (
|
||
<Checkbox key={opt.value} value={opt.value}>
|
||
{opt.label}
|
||
</Checkbox>
|
||
))}
|
||
</Checkbox.Group>
|
||
);
|
||
break;
|
||
case 'textarea':
|
||
inputNode = <Input.TextArea {...commonProps} rows={4} />;
|
||
break;
|
||
case 'file':
|
||
inputNode = (
|
||
<Upload beforeUpload={() => false} maxCount={1}>
|
||
<Button icon={<UploadOutlined />}>点击上传</Button>
|
||
</Upload>
|
||
);
|
||
break;
|
||
default:
|
||
inputNode = <Input {...commonProps} type={field.type === 'tel' ? 'tel' : 'text'} />;
|
||
}
|
||
|
||
const itemProps = {
|
||
key: field.name,
|
||
name: field.name,
|
||
label: field.label,
|
||
rules: [{ required: field.required, message: `请填写${field.label}` }],
|
||
};
|
||
|
||
if (field.type === 'file') {
|
||
itemProps.valuePropName = 'fileList';
|
||
itemProps.getValueFromEvent = normFile;
|
||
}
|
||
|
||
return (
|
||
<Form.Item {...itemProps}>
|
||
{inputNode}
|
||
</Form.Item>
|
||
);
|
||
})}
|
||
</Form>
|
||
</Modal>
|
||
<Modal
|
||
title="微信支付"
|
||
open={paymentModalVisible}
|
||
onCancel={() => setPaymentModalVisible(false)}
|
||
footer={null}
|
||
destroyOnHidden
|
||
width={360}
|
||
zIndex={1001} // 确保层级高于其他弹窗
|
||
>
|
||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||
{paymentInfo?.code_url ? (
|
||
<>
|
||
<div style={{ background: '#fff', padding: 10, display: 'inline-block' }}>
|
||
<QRCodeSVG value={paymentInfo.code_url} size={200} />
|
||
</div>
|
||
<p style={{ marginTop: 20, fontSize: 18, fontWeight: 'bold' }}>¥{paymentInfo.price}</p>
|
||
<p style={{ color: '#666' }}>请使用微信扫一扫支付</p>
|
||
</>
|
||
) : (
|
||
<Spin tip="正在生成二维码..." />
|
||
)}
|
||
<div style={{ marginTop: 20 }}>
|
||
<Button type="primary" onClick={() => window.location.reload()}>
|
||
我已支付
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
</motion.div>
|
||
);
|
||
};
|
||
|
||
export default ActivityDetail;
|