轻松聘
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

679 lines
18 KiB

import { Ionicons } from '@expo/vector-icons';
import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLegalData, useLegalMembership } from '../src/hooks';
import { colors , spacing } from '../src/theme';
import type { LegalFAQ, LegalPlan, LegalService, LegalTemplate } from '../src/types';
/* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack }: { onBack: () => void }) {
return (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>阿布阿诺法律服务</Text>
<View style={styles.headerBtn} />
</View>
);
}
/* ===================== 子组件:品牌卡片 ===================== */
function BrandCard({ name, company, slogan, logo }: { name: string; company: string; slogan: string; logo: string }) {
return (
<View style={styles.brandCard}>
<View style={styles.brandLogo}>
<Text style={styles.brandLogoText}>{logo}</Text>
</View>
<View style={styles.brandBody}>
<Text style={styles.brandName}>{name}</Text>
<Text style={styles.brandCompany}>{company}</Text>
<Text style={styles.brandSlogan}>{slogan}</Text>
</View>
</View>
);
}
/* ===================== 子组件:服务入口 ===================== */
function ServiceCard({
service,
isUnlocked,
onPress,
}: {
service: LegalService;
/** 会员服务是否已解锁(已开通会员) */
isUnlocked: boolean;
onPress: () => void;
}) {
const isMember = service.type === 'member';
return (
<Pressable
style={[
styles.serviceCard,
isMember ? styles.serviceCardMember : styles.serviceCardFree,
]}
onPress={onPress}
>
<View style={styles.serviceIconWrap}>
<Ionicons name={service.icon as keyof typeof Ionicons.glyphMap} size={22} color={isMember ? colors.action : colors.primary} />
</View>
<View style={styles.serviceBody}>
<View style={styles.serviceTitleRow}>
<Text style={styles.serviceTitle}>{service.title}</Text>
{isMember && (
<View style={styles.memberTag}>
<Text style={styles.memberTagText}>{isUnlocked ? '已开通' : '会员'}</Text>
</View>
)}
</View>
<Text style={styles.serviceDesc}>{service.description}</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textHint} />
</Pressable>
);
}
/* ===================== 子组件:FAQ 卡片 ===================== */
function FAQItem({ faq }: { faq: LegalFAQ }) {
const [expanded, setExpanded] = useState(false);
return (
<Pressable style={styles.faqItem} onPress={() => setExpanded((v) => !v)}>
<View style={styles.faqRow}>
<Text style={styles.faqQ}>{faq.question}</Text>
<Ionicons name={expanded ? 'chevron-up' : 'chevron-down'} size={18} color={colors.textHint} />
</View>
{expanded && <Text style={styles.faqA}>{faq.answer}</Text>}
</Pressable>
);
}
/* ===================== 子组件:模板卡片 ===================== */
function TemplateCard({ template }: { template: LegalTemplate }) {
return (
<View style={styles.templateCard}>
<Ionicons name={template.icon as keyof typeof Ionicons.glyphMap} size={22} color={colors.primary} />
<View style={styles.templateBody}>
<Text style={styles.templateName}>{template.name}</Text>
<Text style={styles.templateDesc}>{template.description}</Text>
</View>
</View>
);
}
/* ===================== 子组件:会员套餐 ===================== */
function PlanCard({ plan, onPress }: { plan: LegalPlan; onPress?: () => void }) {
return (
<Pressable
style={({ pressed }) => [
styles.planCard,
plan.recommended && styles.planCardRecommended,
pressed && styles.planPressed,
]}
onPress={onPress}
disabled={!onPress}
>
{plan.recommended && (
<View style={styles.recommendedTag}>
<Text style={styles.recommendedText}>推荐</Text>
</View>
)}
<Text style={styles.planName}>{plan.name}</Text>
<View style={styles.planPriceRow}>
<Text style={styles.planCurrency}>¥</Text>
<Text style={styles.planPrice}>{plan.price}</Text>
<Text style={styles.planPeriod}>{plan.period}</Text>
</View>
<View style={styles.benefits}>
{plan.benefits.map((benefit, idx) => (
<View key={idx} style={styles.benefitRow}>
<Ionicons name="checkmark-circle" size={14} color={colors.success} />
<Text style={styles.benefitText}>{benefit}</Text>
</View>
))}
</View>
<View style={styles.planCtaRow}>
<Text style={styles.planCtaText}>立即开通</Text>
<Ionicons name="chevron-forward" size={16} color={colors.action} />
</View>
</Pressable>
);
}
/* ===================== 页面 ===================== */
export default function LegalScreen() {
const router = useRouter();
const { brand, services, faqs, templates, plans, loading, error, refresh } = useLegalData();
const { membership, refresh: refreshMembership } = useLegalMembership();
// 返回本页时刷新会员态(支付成功后需即时反映)
useFocusEffect(
useCallback(() => {
refreshMembership();
}, [refreshMembership]),
);
const freeServices = services.filter((s) => s.type === 'free');
const memberServices = services.filter((s) => s.type === 'member');
const handleServicePress = (service: LegalService) => {
if (service.action === 'chat') {
router.push('/legal/chat');
} else if (service.action === 'templates') {
router.push('/legal/templates');
} else if (service.action === 'faq') {
router.push('/legal/faq');
} else if (service.action === 'upgrade') {
// 已开通会员 → 进入服务使用页(权益兑现);未开通 → 引导开通
if (membership) {
router.push(`/legal/service?id=${service.id}`);
} else {
router.push('/legal/upgrade');
}
}
};
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
{loading ? (
<View style={styles.stateBox}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
) : error ? (
<View style={styles.stateBox}>
<Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
) : (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{brand && (
<BrandCard
name={brand.name}
company={brand.company}
slogan={brand.slogan}
logo={brand.logo}
/>
)}
{/* 下面两个网格此前没有空态保护,同文件的模板/FAQ/套餐三处都做了 —— 统一补齐 */}
<SectionTitle title="免费法律援助" />
{freeServices.length === 0 ? (
<EmptyGridHint text="暂无免费法律服务" />
) : (
<View style={styles.serviceGrid}>
{freeServices.map((service) => (
<ServiceCard
key={service.id}
service={service}
isUnlocked={false}
onPress={() => handleServicePress(service)}
/>
))}
</View>
)}
<SectionTitle title="会员法律服务" />
{memberServices.length === 0 ? (
<EmptyGridHint text="暂无会员法律服务" />
) : (
<View style={styles.serviceGrid}>
{memberServices.map((service) => (
<ServiceCard
key={service.id}
service={service}
isUnlocked={Boolean(membership)}
onPress={() => handleServicePress(service)}
/>
))}
</View>
)}
{templates.length > 0 && (
<>
<SectionTitle title="文书模板" />
<View style={styles.templateGrid}>
{templates.map((template) => (
<TemplateCard key={template.id} template={template} />
))}
</View>
</>
)}
{faqs.length > 0 && (
<>
<SectionTitle title="常见问题速查" />
<View style={styles.faqCard}>
{faqs.map((faq) => (
<FAQItem key={faq.id} faq={faq} />
))}
</View>
</>
)}
{plans.length > 0 && (
<>
<SectionTitle title="会员套餐" />
{membership && (
<View style={styles.memberBanner}>
<Ionicons name="shield-checkmark" size={16} color={colors.action} />
<Text style={styles.memberBannerText}>
您已开通{membership.planName},可畅享会员专属服务
</Text>
</View>
)}
<View style={styles.planGrid}>
{plans.map((plan) => (
<PlanCard
key={plan.id}
plan={plan}
onPress={() => router.push(`/legal/checkout?planId=${plan.id}`)}
/>
))}
</View>
</>
)}
<Pressable style={styles.consultBtn} onPress={() => router.push('/legal/chat')}>
<Ionicons name="chatbubble-ellipses-outline" size={18} color={colors.white} />
<Text style={styles.consultText}>开始法律咨询</Text>
</Pressable>
</ScrollView>
)}
</SafeAreaView>
);
}
/* ===================== 公共子组件 ===================== */
/** 服务网格的空态提示(区块级,不占满整屏) */
function EmptyGridHint({ text }: { text: string }) {
return (
<View style={styles.emptyHint}>
<Ionicons name="documents-outline" size={28} color={colors.textHint} />
<Text style={styles.emptyHintText}>{text}</Text>
</View>
);
}
function SectionTitle({ title }: { title: string }) {
return (
<View style={styles.sectionHeader}>
<View style={styles.sectionBar} />
<Text style={styles.sectionTitle}>{title}</Text>
</View>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.card,
height: 48,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
scroll: {
flex: 1,
},
content: {
padding: spacing.card,
paddingBottom: 24,
},
brandCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.primaryLight,
borderRadius: spacing.card,
padding: spacing.card,
marginBottom: 8,
borderWidth: 1,
borderColor: colors.primaryBorder,
},
brandLogo: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.white,
alignItems: 'center',
justifyContent: 'center',
},
brandLogoText: {
fontSize: 28,
},
brandBody: {
flex: 1,
marginLeft: 12,
},
brandName: {
fontSize: 18,
fontWeight: '700',
color: colors.primaryText,
},
brandCompany: {
marginTop: 2,
fontSize: 12,
color: colors.textSecondary,
},
brandSlogan: {
marginTop: 4,
fontSize: 13,
fontWeight: '600',
color: colors.primary,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 20,
marginBottom: 10,
},
sectionBar: {
width: 4,
height: 16,
borderRadius: 2,
backgroundColor: colors.primary,
marginRight: 8,
},
sectionTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
serviceGrid: {
gap: 8,
},
emptyHint: {
backgroundColor: colors.surface,
borderRadius: spacing.card,
padding: spacing.card,
alignItems: 'center',
gap: 8,
},
emptyHintText: {
fontSize: 13,
color: colors.textSecondary,
},
serviceCard: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.card,
borderRadius: spacing.card,
backgroundColor: colors.white,
},
serviceCardFree: {
borderLeftWidth: 3,
borderLeftColor: colors.primary,
},
serviceCardMember: {
borderLeftWidth: 3,
borderLeftColor: colors.action,
},
serviceIconWrap: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.pageBg,
alignItems: 'center',
justifyContent: 'center',
},
serviceBody: {
flex: 1,
marginLeft: 12,
marginRight: 8,
},
serviceTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
serviceTitle: {
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
memberTag: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
backgroundColor: colors.actionLight,
},
memberTagText: {
fontSize: 10,
fontWeight: '700',
color: colors.actionText,
},
serviceDesc: {
marginTop: 2,
fontSize: 12,
color: colors.textSecondary,
lineHeight: 17,
},
templateGrid: {
gap: 8,
},
templateCard: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.card,
borderRadius: spacing.card,
backgroundColor: colors.white,
},
templateBody: {
flex: 1,
marginLeft: 12,
},
templateName: {
fontSize: 14,
fontWeight: '700',
color: colors.textPrimary,
},
templateDesc: {
marginTop: 2,
fontSize: 12,
color: colors.textSecondary,
},
faqCard: {
backgroundColor: colors.white,
borderRadius: spacing.card,
paddingHorizontal: spacing.card,
},
faqItem: {
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
faqRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
faqQ: {
flex: 1,
fontSize: 14,
fontWeight: '600',
color: colors.textPrimary,
marginRight: 8,
},
faqA: {
marginTop: 8,
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary,
},
planGrid: {
flexDirection: 'row',
gap: 8,
},
planCard: {
flex: 1,
padding: spacing.card,
borderRadius: spacing.card,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
},
planPressed: {
opacity: 0.85,
},
planCtaRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
marginTop: 12,
gap: 2,
},
planCtaText: {
fontSize: 13,
fontWeight: '700',
color: colors.action,
},
memberBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.card,
paddingVertical: spacing.card,
borderRadius: spacing.card,
backgroundColor: colors.actionLight,
borderWidth: 1,
borderColor: colors.action,
marginBottom: 8,
},
memberBannerText: {
flex: 1,
fontSize: 13,
fontWeight: '600',
color: colors.actionText,
},
planCardRecommended: {
borderColor: colors.action,
backgroundColor: colors.actionLight,
},
recommendedTag: {
alignSelf: 'flex-start',
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
backgroundColor: colors.action,
marginBottom: 8,
},
recommendedText: {
fontSize: 10,
fontWeight: '700',
color: colors.white,
},
planName: {
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
planPriceRow: {
flexDirection: 'row',
alignItems: 'baseline',
marginTop: 8,
marginBottom: 10,
},
planCurrency: {
fontSize: 14,
fontWeight: '700',
color: colors.actionText,
},
planPrice: {
fontSize: 28,
fontWeight: '700',
color: colors.actionText,
},
planPeriod: {
fontSize: 13,
color: colors.textSecondary,
},
benefits: {
gap: 6,
},
benefitRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
benefitText: {
fontSize: 12,
color: colors.textSecondary,
},
consultBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
marginTop: 20,
height: 48,
borderRadius: spacing.card,
backgroundColor: colors.primaryBtn,
},
consultText: {
fontSize: 15,
fontWeight: '700',
color: colors.white,
},
stateBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: spacing.card,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
});